Compare commits

..

67 commits

Author SHA1 Message Date
9f3dc7c993 stash 2026-08-26 22:18:55 +02:00
23185e1721 stash 2026-08-26 22:18:02 +02:00
4c9e8f942e stash 2026-08-26 20:48:22 +02:00
8de6ae8d8b stash 2026-08-26 20:40:23 +02:00
35be834799 stash 2026-08-26 18:37:06 +02:00
9ce700c388 stash 2026-08-26 18:34:28 +02:00
582853ab27 stash 2026-08-26 18:17:12 +02:00
7c2db40eab stash 2026-08-26 17:53:29 +02:00
491ee05f15 stash 2026-08-26 16:35:21 +02:00
1356aa7749 stash 2026-08-26 14:20:02 +02:00
368268d288 stash 2026-08-26 13:17:30 +02:00
fb3c1e78e8 stash 2026-08-26 12:13:27 +02:00
0c025db799 stash 2026-08-24 21:35:51 +02:00
f87b689e27 stash 2026-08-24 19:49:10 +02:00
aa94c92000 stash 2026-08-24 19:08:23 +02:00
ed04d98bf1 stash 2026-08-24 15:57:17 +02:00
8d96bc97c4 stash 2026-08-24 12:36:17 +02:00
8f3236b5b4 stash 2026-08-23 15:55:08 +02:00
54374abf86 stash 2026-08-23 14:22:32 +02:00
bbe52e4a78 stash 2026-08-23 00:56:03 +02:00
5c3b7fc252 stash 2026-08-22 23:48:31 +02:00
de7d9426be stash 2026-08-22 23:29:02 +02:00
3299c97392 stash 2026-08-21 21:37:28 +02:00
7d9f67a77a stash 2026-08-21 19:56:55 +02:00
395a9b156a stash 2026-08-20 05:19:53 +02:00
c345372382 stash 2026-08-20 03:46:11 +02:00
ce1e5f1d62 stash 2026-08-20 01:54:37 +02:00
67c7415c3b stash 2026-08-19 23:09:21 +02:00
4787acd8eb stash 2026-08-19 16:45:20 +02:00
6d2167ac66 add dns recursor to dev setup 2026-08-19 14:14:17 +02:00
eaae7c286a stash 2026-08-17 20:30:22 +02:00
8622e488a4 stash 2026-08-17 20:11:33 +02:00
95ddb484eb stash 2026-08-17 19:30:48 +02:00
accbaf3603 stash 2026-08-17 18:58:42 +02:00
7d7730354e stash 2026-08-17 18:28:01 +02:00
25cef95711 stash 2026-08-17 13:10:23 +02:00
9803f23b8d stash 2026-08-17 12:37:18 +02:00
312f2f6460 stash 2026-08-17 12:10:42 +02:00
d32e718454 stash 2026-08-17 05:22:32 +02:00
dae1528793 stash 2026-08-17 04:35:35 +02:00
419893d93d stash 2026-08-17 03:09:33 +02:00
0f51f6e33f stash 2026-08-16 23:52:58 +02:00
3b494dfa37 stash 2026-08-16 19:00:05 +02:00
82a27ce2a8 stash 2026-08-16 18:58:53 +02:00
c0f70004eb stash 2026-08-16 15:15:38 +02:00
6fcdd1eefa stash 2026-08-01 23:10:08 +02:00
4f2fe011c0 stash 2026-08-01 22:20:45 +02:00
cfcc2c15d3 stash 2026-08-01 16:03:35 +02:00
2f8683add1 stash 2026-08-01 12:26:18 +02:00
32addb8ed1 add account preferences model and API endpoints for user settings 2026-08-01 02:30:09 +02:00
a9d5bbb9df Merge branch 'jedi/cli-client' into jedi/proto/frontend 2026-08-01 02:28:32 +02:00
cbceaaf9dc offline data prototype 2026-08-01 01:49:49 +02:00
7df585d774 stash 2026-07-23 05:17:44 +02:00
796fef81be stash 2026-07-23 04:41:04 +02:00
7c91661be2 stash 2025-09-26 20:00:29 +02:00
4627f0aca2 stash 2025-09-25 16:09:52 +02:00
2fe8d14c2c stash 2025-09-24 22:10:55 +02:00
17bfb84a94 stash 2024-09-02 20:06:49 +02:00
448c166507 add item images 2024-08-31 03:01:00 +02:00
0af1f40b07 add item creation page 2024-08-31 00:21:01 +02:00
f01d513803 add item edit page 2024-08-30 23:09:30 +02:00
b8f1942ab1 add item detail page 2024-08-25 19:39:33 +02:00
8716d3f692 add /inventory page 2024-08-25 19:39:33 +02:00
9770ca861a forward unknown url to dashboard 2024-08-25 19:39:33 +02:00
157e47d3ef add /friends page 2024-08-25 19:39:33 +02:00
c49a40df01 add make login page functional using federation layer and enforce login in router 2024-08-25 19:39:33 +02:00
ca79af5e38 add skeleton for cli-client
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 19:56:46 +02:00
238 changed files with 87134 additions and 2271 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "frontend/extras"]
path = frontend/extras
url = https://git.neulandlabor.de/j3d1/vue-extras.git

View file

@ -1,5 +1,8 @@
# toolshed
## foo
## Development
``` bash
@ -87,4 +90,4 @@ for detailed instructions see [docs](/docs/deployment.md).
``` bash
cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory
```
```

View file

@ -1,2 +1,4 @@
ALLOWED_HOSTS="localhost,127.0.0.1"
ALLOWED_HOSTS="localhost,127.0.0.1"
SERVE_X_ACCEL_REDIRECT=True

View file

@ -1,13 +0,0 @@
FROM python:alpine
WORKDIR /app
RUN apk add --no-cache gcc musl-dev python3-dev
COPY requirements.txt /app
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . /app
RUN python configure.py
RUN python manage.py collectstatic --noinput
CMD python manage.py runserver 0.0.0.0:8000 --insecure
# TODO serve static files with nginx and remove --insecure
EXPOSE 8000

View file

@ -1,6 +1,7 @@
from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
class ToolshedUserAdmin(admin.ModelAdmin):
@ -8,6 +9,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
class AccountPreferenceAdmin(admin.ModelAdmin):
list_display = ('user', 'key', 'value')
search_fields = ('user__username', 'key')
class KnownIdentityAdmin(admin.ModelAdmin):
list_display = ('username', 'domain', 'public_key')
search_fields = ('username', 'domain', 'public_key')
@ -23,7 +29,36 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'domain', 'get_members')
search_fields = ('name', 'domain', 'members__username')
@admin.display(description='Members')
def get_members(self, obj):
return ', '.join(str(member) for member in obj.members.all())
class GroupInviteAdmin(admin.ModelAdmin):
list_display = ('secret', 'group', 'invitee_username', 'invitee_domain')
search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain')
class GroupInviteIncomingAdmin(admin.ModelAdmin):
list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'group_name', 'group_domain', 'created_at')
search_fields = ('user__username', 'group_name', 'group_domain')
admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)
admin.site.register(Group, GroupAdmin)
admin.site.register(GroupInvite, GroupInviteAdmin)
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
admin.site.register(GroupMembership, GroupMembershipAdmin)

View file

@ -9,12 +9,56 @@ from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.response import Response
from authentication.models import ToolshedUser
from authentication.signature_auth import SignatureAuthenticationLocal
from authentication.models import ToolshedUser, AccountPreference
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
split_userhandle_or_throw
from files.models import File
from files.serializers import FileSerializer
from hostadmin.models import Domain
router = routers.SimpleRouter()
# Schema for account-level preferences a client may store server-side (see AccountPreference);
# device-level preferences stay in the browser's local storage instead.
PREFERENCE_DEFINITIONS = [
{
'key': 'ui.compact_mode',
'type': 'boolean',
'default': False,
'label': 'Compact mode',
'description': 'Show denser item rows and reduce spacing in lists.',
},
{
'key': 'ui.default_search_scope',
'type': 'enum',
'options': ['inventory', 'friends', 'all'],
'default': 'inventory',
'label': 'Default search scope',
'description': 'Choose where the global search starts.',
},
{
'key': 'notifications.desktop_enabled',
'type': 'boolean',
'default': True,
'label': 'Desktop notifications',
'description': 'Enable in-browser notifications for important updates.',
},
{
'key': 'files.max_upload_mb',
'type': 'integer',
'default': 25,
'label': 'Default upload size limit (MB)',
'description': 'Used as a prefill hint in upload dialogs.',
},
{
'key': 'ui.experimental_flags',
'type': 'json',
'default': {},
'label': 'Experimental flags',
'description': 'Optional JSON toggles for feature previews.',
},
]
class UserAuthToken(ObtainAuthToken):
@ -53,15 +97,71 @@ class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsAdminUser]
@api_view(['GET'])
@api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request):
"""Get or update the authenticated local user's own account info; only the account owner may
call this on their own home server (see getUserProfile for viewing a friend's public profile)."""
user = request.user
if request.method == 'PATCH':
old_file = user.profile_picture
if 'profile_picture' in request.data:
profile_picture = request.data.get('profile_picture')
if profile_picture is None:
user.profile_picture = None
elif type(profile_picture) == dict:
serializer = FileSerializer(data=profile_picture)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
user.profile_picture = serializer.save()
else:
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
elif 'profile_picture_id' in request.data:
profile_picture_id = request.data.get('profile_picture_id')
if profile_picture_id is None:
user.profile_picture = None
else:
try:
user.profile_picture = File.objects.get(id=profile_picture_id)
except File.DoesNotExist:
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save()
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \
and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0:
old_file.file.delete(save=False)
old_file.delete()
return Response({
'username': user.username,
'domain': user.domain,
'email': user.email
'email': user.email,
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def getUserProfile(request, handle):
"""Get another local user's public profile by handle, e.g. so a friend can look up an avatar;
caller must be a friend of that user (or the user itself, signing with their own known
identity rather than local credentials)."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=400)
try:
target = ToolshedUser.objects.get(username=username, domain=domain)
except ToolshedUser.DoesNotExist:
return Response(status=404)
if target not in request.user.friends_or_self():
return Response(status=403)
return Response({
'username': target.username,
'domain': target.domain,
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
})
@ -99,11 +199,49 @@ def registerUser(request):
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def preference_definitions(request):
"""Return the schema (types, defaults, labels) for the account preferences clients may set."""
return Response(PREFERENCE_DEFINITIONS)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preferences(request):
"""Get or bulk-upsert the authenticated user's account-level preferences: GET returns the
current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified
keys untouched."""
if request.method == 'PUT':
if not isinstance(request.data, dict):
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
for key, value in request.data.items():
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
return Response(preferences)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preference_detail(request, key):
"""Reset a single account-level preference back to its default by deleting it."""
AccountPreference.objects.filter(user=request.user, key=key).delete()
return Response(status=204)
router.register(r'users', UserViewSet)
urlpatterns = [
path('', include(router.urls)),
path('user/', getUserInfo),
path('user/<str:handle>/', getUserProfile),
path('register/', registerUser),
path('token/', UserAuthToken.as_view()),
path('preferences/', preference_definitions),
path('self/preferences/', account_preferences),
path('self/preferences/<str:key>/', account_preference_detail),
]

View file

@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0001_initial'),
('authentication', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='toolsheduser',
name='profile_picture',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='profile_picture_users', to='files.file'),
),
]

View file

@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_toolsheduser_profile_picture'),
]
operations = [
migrations.CreateModel(
name='AccountPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.JSONField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
to='authentication.toolsheduser')),
],
options={
'unique_together': {('user', 'key')},
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2026-08-09 13:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_accountpreference'),
]
operations = [
migrations.AlterField(
model_name='accountpreference',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View file

@ -0,0 +1,56 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_accountpreference_id'),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.CharField(max_length=255)),
('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')),
],
),
migrations.CreateModel(
name='GroupInviteIncoming',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('inviter_username', models.CharField(max_length=255)),
('inviter_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GroupInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('invitee_username', models.CharField(max_length=255)),
('invitee_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')),
],
),
migrations.AddIndex(
model_name='group',
index=models.Index(fields=['name', 'domain'], name='group_idx'),
),
migrations.AlterUniqueTogether(
name='group',
unique_together={('name', 'domain')},
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.2 on 2026-08-26 16:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
]
operations = [
migrations.CreateModel(
name='GroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'group_name', 'group_domain')},
},
),
]

View file

@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
domain = models.CharField(max_length=255, default='localhost')
private_key = models.CharField(max_length=255)
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
related_name='profile_picture_users')
objects = ToolshedUserManager()
class Meta:
@ -111,6 +113,20 @@ class ToolshedUser(AbstractUser):
return self.public_identity.public_key
class AccountPreference(models.Model):
"""A single account-level (server-synced, cross-device) preference as a key/value pair;
device-level preferences are intentionally *not* stored here."""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
key = models.CharField(max_length=255)
value = models.JSONField()
class Meta:
unique_together = ('user', 'key')
def __str__(self):
return f"{self.user}: {self.key}"
class FriendRequestOutgoing(models.Model):
secret = models.CharField(max_length=255)
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
@ -125,3 +141,62 @@ class FriendRequestIncoming(models.Model):
befriender_public_key = models.CharField(max_length=255)
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class Group(models.Model):
name = models.CharField(max_length=255)
domain = models.CharField(max_length=255)
members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups')
class Meta:
unique_together = ('name', 'domain')
indexes = [
models.Index(fields=['name', 'domain'], name='group_idx'),
]
def __str__(self):
return f"#{self.name}@{self.domain}"
def is_member(self, identity):
return self.members.filter(pk=identity.pk).exists()
class GroupInvite(models.Model):
"""A pending invite tracked on the group's own home backend, checked when the invitee's accept
request arrives (mirror: GroupInviteIncoming on the invitee's backend; see
docs/design-in-progress/groups-mvp.md)."""
secret = models.CharField(max_length=255)
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invitee_username = models.CharField(max_length=255)
invitee_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class GroupInviteIncoming(models.Model):
secret = models.CharField(max_length=255)
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
inviter_username = models.CharField(max_length=255)
inviter_domain = models.CharField(max_length=255)
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class GroupMembership(models.Model):
"""A pointer kept on a member's own home backend, recording that the local user is a member
of a group that may be hosted here or on a remote domain (mirrors FriendRequestIncoming's
role: independently recorded on the member's own side, not just the group's authoritative
backend). Written once the invitee's accept request against the group's home backend has
succeeded (see GroupInvitesIncomingAccept), analogous to how a friendship is independently
recorded on both sides via KnownIdentity.friends rather than only on one. See
docs/design-in-progress/groups-mvp.md's 'Known limitation'."""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_memberships')
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'group_name', 'group_domain')
def __str__(self):
return f"{self.user} MEMBER_OF #{self.group_name}@{self.group_domain}"

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from authentication.models import ToolshedUser
from authentication.models import ToolshedUser, Group
class OwnerSerializer(serializers.ReadOnlyField):
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
def to_representation(self, value):
return value.username + '@' + value.domain
class GroupOwnerSerializer(serializers.ReadOnlyField):
class Meta:
model = Group
fields = ['name', 'domain']
def to_representation(self, value):
return f"#{value.name}@{value.domain}"

View file

@ -16,6 +16,12 @@ def split_userhandle_or_throw(userhandle):
return username, domain
def split_grouphandle_or_throw(grouphandle):
if not grouphandle.startswith('#'):
raise ValueError('Group handle must be in the format #name@domain')
return split_userhandle_or_throw(grouphandle[1:])
def verify_request(request, raw_request_body):
authentication_header = request.META.get('HTTP_AUTHORIZATION')
@ -74,6 +80,32 @@ def verify_incoming_friend_request(request, raw_request_body):
return False
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
"""Self-certifying verifier for the group invite/accept dance. See
docs/implementation.md#group-invite-and-accept-self-certifying-verification."""
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
except ValueError:
return False
try:
claimed_handle = request.data[handle_field]
claimed_key = request.data[key_field]
except KeyError:
return False
if not claimed_handle or not claimed_key:
return False
if username + "@" + domain != claimed_handle:
return False
if len(claimed_key) != 64:
return False
verify_key = VerifyKey(bytes.fromhex(claimed_key))
try:
verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex))
return True
except BadSignatureError:
return False
def authenticate_request_against_known_identities(request, raw_request_body):
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
@ -106,11 +138,17 @@ def authenticate_request_against_local_users(request, raw_request_body):
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
return authenticate_request_against_known_identities(
request, request.body.decode('utf-8')), None
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
# Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead
# of treating the request as authenticated with an empty user.
if identity is None:
return None
return identity, None
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
def authenticate(self, request):
return authenticate_request_against_local_users(
request, request.body.decode('utf-8')), None
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
if user is None:
return None
return user, None

View file

@ -3,7 +3,7 @@ import json
from django.test import TestCase, Client
from nacl.encoding import HexEncoder
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from hostadmin.models import Domain
from nacl.signing import SigningKey
@ -86,3 +86,9 @@ class UserTestMixin:
domain=self.f['example_com'].name)
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
class GroupTestMixin:
def prepare_groups(self):
self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name)
self.f['group1'].members.add(self.f['local_user1'].public_identity)

View file

@ -1,4 +1,5 @@
import json
import base64
from django.test import Client, RequestFactory
from nacl.encoding import HexEncoder
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
from files.models import File
class AuthorizationTestCase(ToolshedTestCase):
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertEqual(reply.json()['email'], 'test1@abc.de')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info2(self):
target = "/auth/user/"
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info_patch_profile_picture(self):
content = base64.b64encode(b'user-profile-image').decode('utf-8')
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
'profile_picture': {
'data': content,
'mime_type': 'image/png'
}
})
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.json()['profile_picture'])
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
self.assertEqual(File.objects.count(), 1)
self.f['local_user1'].refresh_from_db()
self.assertIsNotNone(self.f['local_user1'].profile_picture)
def test_user_info_patch_profile_picture_clear(self):
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
self.f['local_user1'].profile_picture = test_file
self.f['local_user1'].save()
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
self.assertEqual(reply.status_code, 200)
self.assertIsNone(reply.json()['profile_picture'])
self.f['local_user1'].refresh_from_db()
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(File.objects.filter(id=test_file.id).exists())
def test_user_info_patch_profile_picture_invalid(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
self.assertEqual(reply.status_code, 400)
def test_user_info_patch_profile_picture_id(self):
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
def test_user_info_patch_profile_picture_id_not_found(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
self.assertEqual(reply.status_code, 400)
def test_user_info_fail(self):
reply = self.anonymous_client.get('/auth/user/')
@ -308,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 403)
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
def setUp(self):
super().setUp()
self.prepare_users()
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
self.anonymous_client = Client(SERVER_NAME='testserver')
self.client = SignatureAuthClient()
def test_view_friend_profile(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
self.assertNotIn('email', reply.json())
def test_view_own_profile_via_handle(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
def test_view_profile_not_friend(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user2'])
self.assertEqual(reply.status_code, 403)
def test_view_profile_unknown_user(self):
target = '/auth/user/nosuchuser@example.com/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_view_profile_bad_handle(self):
target = '/auth/user/notahandle/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 400)
def test_view_profile_unauthenticated(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.anonymous_client.get(target)
self.assertEqual(reply.status_code, 403)
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()

View file

@ -1,12 +1,3 @@
"""
ASGI config for backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application

View file

@ -1,25 +1,25 @@
"""
Django settings for backend project.
Generated by 'django-admin startproject' using Django 4.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/
"""
import os
import subprocess
import dotenv
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
dotenv.load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
def _git_commit():
# Docker dev bind-mounts the real .git dir at /git (see docker-compose.yml); bare-metal dev
# finds it by walking up from BASE_DIR instead. Prod has no .git at all, so both fail and we
# fall back to the GIT_COMMIT build-arg/env var (see Dockerfile.backend/playbook.yml).
cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git']
try:
return subprocess.check_output(
[*cmd, 'rev-parse', '--short', 'HEAD'], cwd=BASE_DIR, stderr=subprocess.DEVNULL
).decode().strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return os.environ.get('GIT_COMMIT', 'unknown')
dotenv.load_dotenv(BASE_DIR / '.env')
SECRET_KEY = os.environ.get('SECRET_KEY', None)
if SECRET_KEY is None:
@ -30,6 +30,7 @@ DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
# Application definition
TOOLSHED_VERSION = "0.0.0-dev.0"
GIT_COMMIT = _git_commit()
INSTALLED_APPS = [
'django.contrib.admin',
@ -86,6 +87,8 @@ CORS_ALLOW_ALL_ORIGINS = True
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SERVE_X_ACCEL_REDIRECT = os.environ.get('SERVE_X_ACCEL_REDIRECT', 'False').lower() == 'true'
ROOT_URLCONF = 'backend.urls'
TEMPLATES = [
@ -106,21 +109,15 @@ TEMPLATES = [
WSGI_APPLICATION = 'backend.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'NAME': os.environ.get('TOOLSHED_DB_PATH', BASE_DIR / 'db.sqlite3'),
}
}
AUTH_USER_MODEL = 'authentication.ToolshedUser'
# Password validation
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -136,9 +133,6 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
@ -147,17 +141,17 @@ USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_ROOT = 'staticfiles'
STATIC_URL = '/static/'
MEDIA_ROOT = 'userfiles'
MEDIA_ROOT = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles')
MEDIA_URL = '/media/'
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
# Pinned explicitly (rather than left to the backend process's ambient umask) so group-read
# is guaranteed for nginx/www-data regardless of how the container is started - see
# SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`.
FILE_UPLOAD_PERMISSIONS = 0o640
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View file

@ -1,18 +1,3 @@
"""backend URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from drf_yasg import openapi
@ -35,9 +20,12 @@ urlpatterns = [
path('auth/', include('authentication.api')),
path('admin/', include('hostadmin.api')),
path('api/', include('toolshed.api.friend')),
path('api/', include('toolshed.api.group')),
path('api/', include('toolshed.api.idmap')),
path('api/', include('toolshed.api.inventory')),
path('api/', include('toolshed.api.info')),
path('api/', include('toolshed.api.files')),
path('api/', include('toolshed.api.offlinedata')),
path('media/', include('files.media_urls')),
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
]

View file

@ -1,12 +1,3 @@
"""
WSGI config for backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application

View file

@ -8,67 +8,80 @@ import dotenv
from django.db import transaction, IntegrityError
class CmdCtx:
def yesno(prompt, default=False):
if not sys.stdin.isatty():
return default
yes = {'yes', 'y', 'ye'}
no = {'no', 'n'}
def __init__(self, args):
self.args = args
if default:
yes.add('')
else:
no.add('')
def yesno(self, prompt, default=False):
if not sys.stdin.isatty() or self.args.noninteractive:
return default
elif self.args.yes:
hint = ' [Y/n] ' if default else ' [y/N] '
while True:
choice = input(prompt + hint).lower()
if choice in yes:
return True
elif self.args.no:
elif choice in no:
return False
yes = {'yes', 'y', 'ye'}
no = {'no', 'n'}
if default:
yes.add('')
else:
no.add('')
hint = ' [Y/n] ' if default else ' [y/N] '
while True:
choice = input(prompt + hint).lower()
if choice in yes:
return True
elif choice in no:
return False
else:
print('Please respond with "yes" or "no"')
print('Please respond with "yes" or "no"')
def configure(ctx):
def configure():
# Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod
# container configured via --env-file) can still print them for the operator to apply manually.
tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS']
unwritable = False
if not os.path.exists('.env'):
if not ctx.yesno("the .env file does not exist, do you want to create it?", default=True):
print('Aborting')
exit(0)
if not os.path.exists('.env.dist'):
print('No .env.dist file found')
exit(1)
else:
from shutil import copyfile
copyfile('.env.dist', '.env')
if yesno("the .env file does not exist, do you want to create it?", default=True):
if not os.path.exists('.env.dist'):
print('No .env.dist file found')
else:
for key in dotenv.dotenv_values('.env.dist'):
if key not in tracked_keys:
tracked_keys.append(key)
from shutil import copyfile
try:
copyfile('.env.dist', '.env')
except PermissionError:
unwritable = True
env = dotenv.load_dotenv('.env')
if not env or not os.getenv('SECRET_KEY'):
dotenv.load_dotenv('.env')
if not os.getenv('SECRET_KEY'):
from django.core.management.utils import get_random_secret_key
print('No SECRET_KEY found in .env file, generating one...')
with open('.env', 'a') as f:
f.write('\nSECRET_KEY=')
f.write(get_random_secret_key())
f.write('\n')
secret_key = get_random_secret_key()
os.environ['SECRET_KEY'] = secret_key
try:
with open('.env', 'a') as f:
f.write('\nSECRET_KEY=')
f.write(secret_key)
f.write('\n')
except PermissionError:
unwritable = True
# TODO rename ALLOWED_HOSTS to something more self-explanatory
current_hosts = os.getenv('ALLOWED_HOSTS')
print('Current ALLOWED_HOSTS: {}'.format(current_hosts))
if ctx.yesno("Do you want to add ALLOWED_HOSTS?"):
if yesno("Do you want to add ALLOWED_HOSTS?"):
hosts = input("Enter a comma-separated list of allowed hosts: ")
joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts
dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts)
os.environ['ALLOWED_HOSTS'] = joined_hosts
try:
dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts)
except PermissionError:
unwritable = True
if unwritable:
print('Could not write .env (read-only working directory) - resulting configuration:')
for key in tracked_keys:
print('{}={}'.format(key, os.getenv(key, '')))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
import django
@ -76,21 +89,20 @@ def configure(ctx):
django.setup()
if not os.path.exists('db.sqlite3'):
if not ctx.yesno("No database found, do you want to create one?", default=True):
if not yesno("No database found, do you want to create one?", default=True):
print('Aborting')
exit(0)
from django.core.management import call_command
call_command('migrate')
if ctx.yesno("Do you want to create a superuser?"):
if yesno("Do you want to create a superuser?"):
from django.core.management import call_command
call_command('createsuperuser')
call_command('collectstatic', '--no-input')
if ctx.yesno("Do you want to import all categories, properties and tags contained in this repository?",
default=True):
if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
from hostadmin.serializers import CategorySerializer, PropertySerializer, TagSerializer
from hostadmin.models import ImportedIdentifierSets
from hashlib import sha256
@ -193,7 +205,11 @@ def testdata():
import django
django.setup()
if os.path.exists('testdata.py'):
testdata_path = os.environ.get('TOOLSHED_SETUP_PATH', 'testdata.py')
if os.path.exists(testdata_path):
if testdata_path != 'testdata.py':
import sys
sys.path.append(os.path.dirname(testdata_path))
from testdata import create_test_data
create_test_data()
else:
@ -206,7 +222,6 @@ def main():
parser = ArgumentParser(description='Toolshed Server Configuration')
parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true')
parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true')
parser.add_argument('--noninteractive', '-x', help="Run in noninteractive mode", action='store_true')
parser.add_argument('cmd', help='Command', default='configure', nargs='?')
args = parser.parse_args()
@ -214,10 +229,8 @@ def main():
print('Error: --yes and --no are mutually exclusive')
exit(1)
ctx = CmdCtx(args)
if args.cmd == 'configure':
configure(ctx)
configure()
elif args.cmd == 'reset':
reset()
elif args.cmd == 'testdata':

View file

@ -1,6 +1,17 @@
import io
import os
from datetime import timedelta
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http import HttpResponse
from django.urls import path
from django.db.models import Q
from django.conf import settings
from django.utils.http import http_date
from django.utils.timezone import now
from drf_yasg.utils import swagger_auto_schema
from PIL import Image
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated
@ -9,27 +20,127 @@ from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication
from files.models import File
THUMBNAIL_SIZES = (32, 64, 256)
def _accessible_files(request):
# Shared by media_urls and thumbnail_urls: a file is visible if the requester is
# friends-or-self with whatever references it (item, profile picture), or it's their own staged photo.
return File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self()) |
Q(staged_by_workflows__owner__in=request.user.user.all())
).distinct()
def _cache_headers(etag):
# Content is hash-addressed and can never change under a given URL, so it's cacheable forever.
return {
'ETag': etag,
'Cache-Control': 'max-age=31536000, private, immutable',
'Expires': http_date((now() + timedelta(days=365)).timestamp()),
}
@swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def media_urls(request, hash_path):
# CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's
# /redirect_media/ block must set it instead.
#
# Looked up by the derived storage path, not the raw hash, to match FileSerializer.name
# (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase).
try:
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
file=hash_path)
file = _accessible_files(request).get(file=hash_path)
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
headers={
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
'Access-Control-Allow-Origin': '*',
}) # TODO Expires and Cache-Control
# Must run before this check, else a bare hash + If-None-Match would let anyone probe
# file existence for files they can't see.
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
cache_headers = _cache_headers(file.hash)
if settings.SERVE_X_ACCEL_REDIRECT:
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
headers={
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
**cache_headers,
})
else:
# Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend.
with file.file.open('rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
headers=cache_headers,
content=content)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
def _thumbnail_rel_path(file_hash, size):
# Mirrors hash_upload()'s sharding under thumbnails/<size>/, reachable via the same nginx alias as originals.
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
file_hash[6:] + '.jpg')
@swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def thumbnail_urls(request, size, hash_path):
if size not in THUMBNAIL_SIZES:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = _accessible_files(request).get(file=hash_path)
etag = f'{file.hash}_{size}'
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
# Read/write via default_storage, not a hand-rolled path, to work with both real-disk
# and in-memory test storage.
rel_path = _thumbnail_rel_path(file.hash, size)
if not default_storage.exists(rel_path):
# Always re-encoded as JPEG regardless of original format - simpler than preserving transparency at this scale.
with file.file.open('rb') as fh:
image = Image.open(fh)
image.thumbnail((size, size))
# Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
rgba = image.convert('RGBA')
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
flattened.paste(rgba, mask=rgba.getchannel('A'))
buffer = io.BytesIO()
flattened.save(buffer, 'JPEG', quality=90)
default_storage.save(rel_path, ContentFile(buffer.getvalue()))
cache_headers = _cache_headers(etag)
if settings.SERVE_X_ACCEL_REDIRECT:
return HttpResponse(status=status.HTTP_200_OK,
content_type='image/jpeg',
headers={
'X-Accel-Redirect': f'/redirect_media/{rel_path}',
**cache_headers,
})
else:
with default_storage.open(rel_path, 'rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK,
content_type='image/jpeg',
headers=cache_headers,
content=content)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
urlpatterns = [
path('<int:size>/<path:hash_path>/', thumbnail_urls),
path('<path:hash_path>', media_urls),
]

View file

@ -1,4 +1,7 @@
from types import SimpleNamespace
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models, IntegrityError
from django.db.models import Model
@ -40,6 +43,10 @@ class FileManager(models.Manager):
else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
if not self.filter(hash=kwargs['hash']).exists():
# Clears a stale orphan already at this hash's canonical path before saving. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path.
expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '')
if default_storage.exists(expected_path):
default_storage.delete(expected_path)
return super().create(**kwargs)
else:
raise IntegrityError('File with this hash already exists')

View file

@ -1,13 +1,20 @@
import io
import os
import zlib
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import DefaultStorage
from django.core.files.storage import DefaultStorage, default_storage
from django.db import IntegrityError, transaction
from django.test import Client
from django.test import Client, override_settings
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
from toolshed.tests import InventoryTestMixin
from nacl.hash import sha256
from nacl.encoding import HexEncoder
from PIL import Image
import base64
from files.media_urls import THUMBNAIL_SIZES
from files.models import File
anonymous_client = Client()
@ -105,6 +112,18 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
self.assertEqual(File.objects.count(), 3)
self.assertEqual(countdir(DefaultStorage(), ''), 3)
def test_file_upload_reclaims_stale_orphan_at_canonical_path(self):
# Regression test for a stale orphan at the canonical hash path. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path.
expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}"
default_storage.save(expected_path, ContentFile(self.f['test_content4']))
self.assertTrue(default_storage.exists(expected_path))
self.assertFalse(File.objects.filter(hash=self.f['hash4']).exists())
file = File.objects.create(mime_type='text/plain', data=self.f['encoded_content4'])
self.assertEqual(file.file.name, expected_path)
self.assertEqual(file.file.read(), self.f['test_content4'])
class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self):
@ -120,6 +139,7 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['item2'].files.add(self.f['test_file1'])
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_file_url(self):
reply = client.get(
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
@ -165,3 +185,159 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertTrue('X-Accel-Redirect' not in reply.headers)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
def test_profile_picture_url_not_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_files()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
self.prepare_inventory()
# Each test uses a distinct seeded image (own hash/cache path) since InMemoryStorage
# isn't reset between test methods, so a shared image risks one test's cached thumbnail leaking into another's assertions.
seed = zlib.crc32(self._testMethodName.encode()) % 256
buffer = io.BytesIO()
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
image_bytes = buffer.getvalue()
self.f['image_hash'] = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
self.f['image_file'] = File.objects.create(
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
self.f['item1'].files.add(self.f['image_file'])
def _thumb_url(self, size, image_hash=None):
h = image_hash or self.f['image_hash']
return f"/media/{size}/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}/"
def _thumb_rel_path(self, size):
h = self.f['image_hash']
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
def test_thumbnail_sizes_available(self):
# Fixed size allow-list this suite exercises - update both if media_urls.py's THUMBNAIL_SIZES changes.
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_generates_resized_jpeg(self):
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.headers['Content-Type'], 'image/jpeg')
generated = Image.open(io.BytesIO(reply.content))
self.assertEqual(generated.format, 'JPEG')
# Aspect-ratio-preserving fit within a 64x64 box, not a crop to exactly 64x64.
self.assertLessEqual(max(generated.size), 64)
self.assertAlmostEqual(generated.size[0] / generated.size[1], 800 / 600, places=2)
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
# Regression test for an 'LA' source with zeroed transparent-region luminance. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
half_transparent = Image.new('LA', (200, 200))
pixels = half_transparent.load()
for x in range(200):
for y in range(200):
if x < 100:
pixels[x, y] = (0, 0) # transparent, zeroed-out luminance underneath
else:
pixels[x, y] = (255, 255) # fully opaque, bright content
buffer = io.BytesIO()
half_transparent.save(buffer, 'PNG')
image_bytes = buffer.getvalue()
image_hash = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
image_file = File.objects.create(
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
self.f['item1'].files.add(image_file)
reply = client.get(self._thumb_url(64, image_hash=image_hash), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
generated = Image.open(io.BytesIO(reply.content)).convert('L')
# The opaque (right) half must stay bright; a naive RGB conversion would blacken it too.
self.assertGreater(generated.getpixel((generated.width - 1, generated.height // 2)), 200)
self.assertNotEqual(generated.getextrema(), (0, 0))
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_served_from_cache_on_second_request(self):
client.get(self._thumb_url(64), self.f['local_user1'])
rel_path = self._thumb_rel_path(64)
with default_storage.open(rel_path, 'rb') as f:
cached_bytes = f.read()
# Overwrites the cache with a marker so a correct implementation must serve it back, not regenerate.
default_storage.delete(rel_path)
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.content.endswith(b'MARKER'))
def test_thumbnail_invalid_size(self):
reply = client.get(self._thumb_url(100), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
self.assertFalse(default_storage.exists(self._thumb_rel_path(100)))
def test_thumbnail_not_found(self):
reply = client.get(self._thumb_url(64, image_hash='0' * 64), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
def test_thumbnail_anonymous(self):
reply = anonymous_client.get(self._thumb_url(64))
self.assertEqual(reply.status_code, 403)
def test_thumbnail_not_friend(self):
# local_user1/local_user2 are friends here (see prepare_inventory), so the denied case needs a stranger instead.
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
def test_thumbnail_conditional_get(self):
reply = client.get(self._thumb_url(64), self.f['local_user1'])
etag = reply.headers['ETag']
self.assertEqual(etag, f"{self.f['image_hash']}_64")
reply = client.get(self._thumb_url(64), self.f['local_user1'], HTTP_IF_NONE_MATCH=etag)
self.assertEqual(reply.status_code, 304)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_thumbnail_x_accel_redirect(self):
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
h = self.f['image_hash']
self.assertEqual(reply.headers['X-Accel-Redirect'],
f"/redirect_media/thumbnails/64/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}.jpg")

View file

@ -41,6 +41,10 @@ class DomainSerializer(serializers.ModelSerializer):
class CategorySerializer(serializers.ModelSerializer):
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@ -56,13 +60,17 @@ class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['name', 'description', 'parent', 'origin']
read_only_fields = ['origin']
fields = ['name', 'description', 'parent', 'origin', 'handle']
read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminCategory'
class PropertySerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@ -79,13 +87,17 @@ class PropertySerializer(serializers.ModelSerializer):
class Meta:
model = Property
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
'dimensions', 'origin']
read_only_fields = ['origin']
'dimensions', 'origin', 'handle']
read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminProperty'
class TagSerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@ -101,6 +113,6 @@ class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ['name', 'description', 'category', 'origin']
read_only_fields = ['origin']
fields = ['name', 'description', 'category', 'origin', 'handle']
read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminTag'

View file

@ -25,6 +25,7 @@ MarkupSafe==2.1.3
openapi-codec==1.3.2
packaging==23.1
pycparser==2.21
Pillow==10.4.0
PyNaCl==1.5.0
python-dotenv==1.0.0
pytz==2023.3

View file

@ -0,0 +1,59 @@
{
"categories": [
{ "name": "hardware"},
{ "name": "material"},
{ "name": "tools"}
],
"properties": [
{ "name": "angle", "unit_symbol": "°", "unit_name": "degree", "unit_name_plural": "degrees" },
{ "name": "area", "unit_symbol": "m²", "unit_name": "square meter", "unit_name_plural": "square meters" },
{ "name": "current", "unit_symbol": "A", "unit_name": "ampere", "unit_name_plural": "amperes" },
{ "name": "diameter", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "energy", "unit_symbol": "J", "unit_name": "joule", "unit_name_plural": "joules" },
{ "name": "frequency", "unit_symbol": "Hz", "unit_name": "hertz", "unit_name_plural": "hertz" },
{ "name": "height", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "length", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "memory", "unit_symbol": "B", "unit_name": "byte", "unit_name_plural": "bytes", "base2_prefix": true },
{ "name": "power", "unit_symbol": "W", "unit_name": "watt", "unit_name_plural": "watts" },
{ "name": "price", "unit_symbol": "€", "unit_name": "euro", "unit_name_plural": "euros" },
{ "name": "speed", "unit_symbol": "m/s", "unit_name": "meter per second", "unit_name_plural": "meters per second" },
{ "name": "temperature", "unit_symbol": "°C", "unit_name": "degree Celsius", "unit_name_plural": "degrees Celsius" },
{ "name": "time", "unit_symbol": "s", "unit_name": "second", "unit_name_plural": "seconds" },
{ "name": "voltage", "unit_symbol": "V", "unit_name": "volt", "unit_name_plural": "volts" },
{ "name": "volume", "unit_symbol": "l", "unit_name": "liter", "unit_name_plural": "liters" },
{ "name": "weight", "unit_symbol": "g", "unit_name": "gram", "unit_name_plural": "grams" },
{ "name": "width", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" }
],
"tags": [
{"name": "bolt", "category": "hardware"},
{"name": "chisel", "category": "tools"},
{"name": "clamp", "category": "tools"},
{"name": "drill", "category": "tools"},
{"name": "ear plugs", "category": "tools"},
{"name": "extension cord", "category": "tools"},
{"name": "flashlight", "category": "tools"},
{"name": "gloves", "category": "tools"},
{"name": "goggles", "category": "tools"},
{"name": "hammer", "category": "tools"},
{"name": "level", "category": "tools"},
{"name": "mask", "category": "tools"},
{"name": "nail", "category": "hardware"},
{"name": "nut", "category": "hardware"},
{"name": "paint brush", "category": "tools"},
{"name": "paint roller", "category": "tools"},
{"name": "paint tray", "category": "tools"},
{"name": "pliers", "category": "tools"},
{"name": "power strip", "category": "tools"},
{"name": "sander", "category": "tools"},
{"name": "saw", "category": "tools"},
{"name": "screw", "category": "hardware"},
{"name": "screwdriver", "category": "tools"},
{"name": "soldering iron", "category": "tools"},
{"name": "stapler", "category": "tools"},
{"name": "tape measure", "category": "tools"},
{"name": "tool"},
{"name": "vise", "category": "tools"},
{"name": "washer", "category": "hardware"},
{"name": "wrench", "category": "tools"}
]
}

View file

@ -0,0 +1,92 @@
{
"depends": [ "git:base" ],
"categories": [
{ "name": "electronics", "parent": "hardware"},
{ "name": "electronics", "parent": "tools"},
{ "name": "bus", "parent": "hardware/electronics"},
{ "name": "mcu", "parent": "hardware/electronics"},
{ "name": "wireless", "parent": "hardware/electronics"}
],
"tags": [
{"name": "smt", "category": "hardware/electronics"},
{"name": "tht", "category": "hardware/electronics"},
{"name": "adapter", "category": "hardware/electronics"},
{"name": "amperemeter", "category": "tools/electronics"},
{"name": "cable", "category": "hardware/electronics"},
{"name": "camera", "category": "tools/electronics"},
{"name": "connector", "category": "hardware/electronics"},
{"name": "flux", "category": "hardware/electronics"},
{"name": "microscope", "category": "tools/electronics"},
{"name": "multimeter", "category": "tools/electronics"},
{"name": "oscilloscope", "category": "tools/electronics"},
{"name": "power supply", "category": "hardware/electronics"},
{"name": "solder", "category": "hardware/electronics"},
{"name": "soldering", "category": "tools/electronics"},
{"name": "voltmeter", "category": "tools/electronics"},
{"name": "charger", "category": "hardware/electronics"},
{"name": "actuator", "category": "hardware/electronics"},
{"name": "battery", "category": "hardware/electronics"},
{"name": "capacitor", "category": "hardware/electronics"},
{"name": "diode", "category": "hardware/electronics"},
{"name": "display", "category": "hardware/electronics"},
{"name": "encoder", "category": "hardware/electronics"},
{"name": "fuse", "category": "hardware/electronics"},
{"name": "inductor", "category": "hardware/electronics"},
{"name": "inverter", "category": "hardware/electronics"},
{"name": "lcd", "category": "hardware/electronics"},
{"name": "led", "category": "hardware/electronics"},
{"name": "motor", "category": "hardware/electronics"},
{"name": "oscillator", "category": "hardware/electronics"},
{"name": "potentiometer", "category": "hardware/electronics"},
{"name": "relay", "category": "hardware/electronics"},
{"name": "resistor", "category": "hardware/electronics"},
{"name": "sensor", "category": "hardware/electronics"},
{"name": "servo", "category": "hardware/electronics"},
{"name": "stepper", "category": "hardware/electronics"},
{"name": "switch", "category": "hardware/electronics"},
{"name": "thermistor", "category": "hardware/electronics"},
{"name": "thermocouple", "category": "hardware/electronics"},
{"name": "transformer", "category": "hardware/electronics"},
{"name": "transistor", "category": "hardware/electronics"},
{"name": "bluetooth", "category": "hardware/electronics/wireless"},
{"name": "gps", "category": "hardware/electronics/wireless"},
{"name": "gsm", "category": "hardware/electronics/wireless"},
{"name": "lora", "category": "hardware/electronics/wireless"},
{"name": "nfc", "category": "hardware/electronics/wireless"},
{"name": "rfid", "category": "hardware/electronics/wireless"},
{"name": "thread", "category": "hardware/electronics/wireless"},
{"name": "wifi", "category": "hardware/electronics/wireless"},
{"name": "zigbee", "category": "hardware/electronics/wireless"},
{"name": "zwave", "category": "hardware/electronics/wireless"},
{"name": "can", "category": "hardware/electronics/bus"},
{"name": "ethernet", "category": "hardware/electronics/bus"},
{"name": "i2c", "category": "hardware/electronics/bus"},
{"name": "lin", "category": "hardware/electronics/bus"},
{"name": "spi", "category": "hardware/electronics/bus"},
{"name": "uart", "category": "hardware/electronics/bus"},
{"name": "usb", "category": "hardware/electronics/bus"},
{"name": "arduino", "category": "hardware/electronics/mcu"},
{"name": "atmega", "category": "hardware/electronics/mcu"},
{"name": "attiny", "category": "hardware/electronics/mcu"},
{"name": "beaglebone", "category": "hardware/electronics/mcu"},
{"name": "esp32", "category": "hardware/electronics/mcu"},
{"name": "esp8266", "category": "hardware/electronics/mcu"},
{"name": "nucleo", "category": "hardware/electronics/mcu"},
{"name": "raspberry", "category": "hardware/electronics/mcu"},
{"name": "stm32", "category": "hardware/electronics/mcu"},
{"name": "stm8", "category": "hardware/electronics/mcu"},
{"name": "6502", "category": "hardware/electronics/mcu"},
{"name": "8051", "category": "hardware/electronics/mcu"},
{"name": "arm", "category": "hardware/electronics/mcu"},
{"name": "avr", "category": "hardware/electronics/mcu"},
{"name": "cortex", "category": "hardware/electronics/mcu"},
{"name": "m68k", "category": "hardware/electronics/mcu"},
{"name": "mips", "category": "hardware/electronics/mcu"},
{"name": "pic", "category": "hardware/electronics/mcu"},
{"name": "powerpc", "category": "hardware/electronics/mcu"},
{"name": "risc-v", "category": "hardware/electronics/mcu"},
{"name": "x86", "category": "hardware/electronics/mcu"},
{"name": "xtensa", "category": "hardware/electronics/mcu"},
{"name": "z80", "category": "hardware/electronics/mcu"}
]
}

View file

@ -0,0 +1,99 @@
{
"depends": [ "git:ee" ],
"categories": [
{ "name": "packages", "parent": "hardware/electronics", "description": "Component Packages"}
],
"tags": [
{"name": "0201", "category": "hardware/electronics/packages"},
{"name": "0402", "category": "hardware/electronics/packages"},
{"name": "0603", "category": "hardware/electronics/packages"},
{"name": "0805", "category": "hardware/electronics/packages"},
{"name": "1206", "category": "hardware/electronics/packages"},
{"name": "1210", "category": "hardware/electronics/packages"},
{"name": "1812", "category": "hardware/electronics/packages"},
{"name": "MELF", "category": "hardware/electronics/packages"},
{"name": "MiniMELF", "category": "hardware/electronics/packages"},
{"name": "MicroMELF", "category": "hardware/electronics/packages"},
{"name": "SMC", "category": "hardware/electronics/packages"},
{"name": "SMB", "category": "hardware/electronics/packages"},
{"name": "SMA", "category": "hardware/electronics/packages"},
{"name": "GF1", "category": "hardware/electronics/packages"},
{"name": "DIP-x", "category": "hardware/electronics/packages",
"description": "Dual Inline Package, needs pin count property to be unambiguous"},
{"name": "SOD", "category": "hardware/electronics/packages"},
{"name": "SOD-123", "category": "hardware/electronics/packages"},
{"name": "SOD-323", "category": "hardware/electronics/packages"},
{"name": "SOD-523", "category": "hardware/electronics/packages"},
{"name": "SOD-923", "category": "hardware/electronics/packages"},
{"name": "SOT", "category": "hardware/electronics/packages"},
{"name": "SOT23", "category": "hardware/electronics/packages"},
{"name": "SOT23-3", "category": "hardware/electronics/packages"},
{"name": "SOT323", "category": "hardware/electronics/packages"},
{"name": "SOT416", "category": "hardware/electronics/packages"},
{"name": "SOT23-5", "category": "hardware/electronics/packages"},
{"name": "SOT353", "category": "hardware/electronics/packages"},
{"name": "SOT553", "category": "hardware/electronics/packages"},
{"name": "SOT23-6", "category": "hardware/electronics/packages"},
{"name": "SOT363", "category": "hardware/electronics/packages"},
{"name": "SOT563", "category": "hardware/electronics/packages"},
{"name": "SOT23-8", "category": "hardware/electronics/packages"},
{"name": "SOT54", "category": "hardware/electronics/packages", "alias":"TO-92"},
{"name": "SOT143", "category": "hardware/electronics/packages"},
{"name": "SOT343", "category": "hardware/electronics/packages"},
{"name": "SOT490", "category": "hardware/electronics/packages"},
{"name": "SOT89-3", "category": "hardware/electronics/packages"},
{"name": "SOT89-5", "category": "hardware/electronics/packages"},
{"name": "SOT223-4", "category": "hardware/electronics/packages"},
{"name": "SOT223-5", "category": "hardware/electronics/packages"},
{"name": "SOT223-8", "category": "hardware/electronics/packages"},
{"name": "TO-3", "category": "hardware/electronics/packages"},
{"name": "TO-5", "category": "hardware/electronics/packages"},
{"name": "TO-8", "category": "hardware/electronics/packages"},
{"name": "TO-18", "category": "hardware/electronics/packages"},
{"name": "TO-39", "category": "hardware/electronics/packages"},
{"name": "TO-66", "category": "hardware/electronics/packages"},
{"name": "TO-92", "category": "hardware/electronics/packages"},
{"name": "TO-220", "category": "hardware/electronics/packages"},
{"name": "TO-247", "category": "hardware/electronics/packages"},
{"name": "TO-251", "category": "hardware/electronics/packages"},
{"name": "TO-252", "category": "hardware/electronics/packages"},
{"name": "TO-263", "category": "hardware/electronics/packages"},
{"name": "TO-264", "category": "hardware/electronics/packages"},
{"name": "TO-268", "category": "hardware/electronics/packages"},
{"name": "TO-269", "category": "hardware/electronics/packages"},
{"name": "SOIC-x", "category": "hardware/electronics/packages", "alias": "SO-x",
"description": "Small Outline Integrated Circuit, needs pin count property to be unambiguous"},
{"name": "SOJ-x", "category": "hardware/electronics/packages",
"description": "Small Outline J-leaded, needs pin count property to be unambiguous"},
{"name": "MSOP-x", "category": "hardware/electronics/packages",
"description": "Mini Small Outline Package, needs pin count property to be unambiguous"},
{"name": "SSOP-x", "category": "hardware/electronics/packages",
"description": "Shrink Small Outline Package, needs pin count property to be unambiguous"},
{"name": "SOP-x", "category": "hardware/electronics/packages",
"description": "Small Outline Package, needs pin count property to be unambiguous"},
{"name": "TSOP-x", "category": "hardware/electronics/packages",
"description": "Thin Small Outline Package, needs pin count property to be unambiguous"},
{"name": "TSSOP-x", "category": "hardware/electronics/packages",
"description": "Thin Shrink Small Outline Package, needs pin count property to be unambiguous"},
{"name": "QFP-x", "category": "hardware/electronics/packages",
"description": "Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "TQFP-x", "category": "hardware/electronics/packages",
"description": "Thin Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "LQFP-x", "category": "hardware/electronics/packages",
"description": "Low-profile Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "DFN-x", "category": "hardware/electronics/packages",
"description": "Dual Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "QFN-x", "category": "hardware/electronics/packages",
"description": "Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "TQFN-x", "category": "hardware/electronics/packages",
"description": "Thin Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "LQFN-x", "category": "hardware/electronics/packages",
"description": "Low-profile Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "UQFN-x", "category": "hardware/electronics/packages",
"description": "Ultra-thin Quad Flat No-leaded, needs pin count property to be unambiguous"}
],
"properties": [
{ "name": "pin count", "unit_symbol": "", "unit_name": "", "unit_name_plural": "" }
],
"url": "https://en.wikipedia.org/wiki/List_of_integrated_circuit_packaging_types"
}

View file

@ -0,0 +1,62 @@
{
"depends": ["git:base"],
"categories": [
{ "name": "electrical"},
{ "name": "connectors"},
{ "name": "power", "parent": "connectors" }
],
"tags": [
{ "name": "braker", "category": "electrical" },
{ "name": "cable", "category": "electrical" },
{ "name": "connector", "category": "electrical" },
{ "name": "plug", "category": "connectors" },
{ "name": "socket", "category": "connectors" },
{ "name": "power", "category": "connectors" },
{ "name": "C1", "category": "connectors/power" },
{ "name": "C2", "category": "connectors/power" },
{ "name": "C3", "category": "connectors/power" },
{ "name": "C4", "category": "connectors/power" },
{ "name": "C5", "category": "connectors/power" },
{ "name": "C6", "category": "connectors/power" },
{ "name": "C7", "category": "connectors/power" },
{ "name": "C7P", "category": "connectors/power" },
{ "name": "C8", "category": "connectors/power" },
{ "name": "C8P", "category": "connectors/power" },
{ "name": "C9", "category": "connectors/power" },
{ "name": "C10", "category": "connectors/power" },
{ "name": "C11", "category": "connectors/power" },
{ "name": "C12", "category": "connectors/power" },
{ "name": "C13", "category": "connectors/power" },
{ "name": "C14", "category": "connectors/power" },
{ "name": "C15", "category": "connectors/power" },
{ "name": "C15A", "category": "connectors/power" },
{ "name": "C16", "category": "connectors/power" },
{ "name": "C16A", "category": "connectors/power" },
{ "name": "C17", "category": "connectors/power" },
{ "name": "C18", "category": "connectors/power" },
{ "name": "C19", "category": "connectors/power" },
{ "name": "C20", "category": "connectors/power" },
{ "name": "C21", "category": "connectors/power" },
{ "name": "C22", "category": "connectors/power" },
{ "name": "C23", "category": "connectors/power" },
{ "name": "C24", "category": "connectors/power" },
{ "name": "Type A", "category": "connectors/power", "description": "NEMA 1-15, U.S. 2 pin" },
{ "name": "Type B", "category": "connectors/power", "description": "NEMA 5-15, U.S. 3 pin" },
{ "name": "Type C", "category": "connectors/power", "description": "CEE 7/16, Europlug" },
{ "name": "Type D", "category": "connectors/power", "description": "BS 546, India 5A/15A" },
{ "name": "Type E", "category": "connectors/power", "description": "CEE 7/5, French 2 pin" },
{ "name": "Type F", "category": "connectors/power", "description": "CEE 7/4, Schuko" },
{ "name": "Type E/F", "category": "connectors/power", "description": "CEE 7/7, Schuko/French hybrid" },
{ "name": "Type G", "category": "connectors/power", "description": "BS 1363, U.K. 3 pin" },
{ "name": "Type H", "category": "connectors/power", "description": "SI 32 Israel"},
{ "name": "Type I", "category": "connectors/power", "description": "AS/NZS 3112, Australia 3 pin" },
{ "name": "Type J", "category": "connectors/power", "description": "SEV 1011, Swiss 3 pin" },
{ "name": "Type K", "category": "connectors/power", "description": "DS 60884-2-D1, Danish 3 pin" },
{ "name": "Type L", "category": "connectors/power", "description": "CEI 23-16/VII, Italian 3 pin" }
],
"properties": [
{ "name": "max current", "description": "Current rating"},
{ "name": "max power", "description": "Power rating" },
{ "name": "grid frequency", "description": "Typical frequency" }
]
}

View file

@ -0,0 +1,82 @@
{
"depends": [ "git:electrical" ],
"categories": [
{ "name": "pc", "description": "PC related" },
{ "name": "usb", "parent": "connectors" }
],
"tags": [
{ "name": "case", "category": "pc" },
{ "name": "cooler", "category": "pc" },
{ "name": "cpu" },
{ "name": "drone" },
{ "name": "fan", "category": "pc" },
{ "name": "gpu" },
{ "name": "hdd" },
{ "name": "headset" },
{ "name": "hub" },
{ "name": "iot" },
{ "name": "keyboard" },
{ "name": "laptop" },
{ "name": "memory" },
{ "name": "microphone" },
{ "name": "monitor" },
{ "name": "motherboard" },
{ "name": "mouse" },
{ "name": "pc"},
{ "name": "power supply" },
{ "name": "printer" },
{ "name": "router" },
{ "name": "scanner" },
{ "name": "server" },
{ "name": "smartphone" },
{ "name": "smartwatch" },
{ "name": "speaker" },
{ "name": "ssd" },
{ "name": "switch" },
{ "name": "tablet" },
{ "name": "watercooling" , "category": "pc"},
{ "name": "webcam" },
{ "name": "workstation" },
{ "name": "USB", "category": "connectors" },
{ "name": "Type A", "category": "connectors/usb" },
{ "name": "Type B", "category": "connectors/usb" },
{ "name": "Type C", "category": "connectors/usb" },
{ "name": "Micro", "category": "connectors/usb" },
{ "name": "Mini", "category": "connectors/usb" },
{ "name": "USB 2.0", "category": "connectors/usb" },
{ "name": "USB 3.0", "category": "connectors/usb" },
{ "name": "USB 3.1", "category": "connectors/usb" },
{ "name": "USB 3.2", "category": "connectors/usb" },
{ "name": "OTG", "category": "connectors/usb" },
{ "name": "thunderbolt", "category": "connectors/usb" },
{ "name": "24pin", "category": "connectors" },
{ "name": "8pin", "category": "connectors" },
{ "name": "atx", "category": "connectors" },
{ "name": "chinch", "category": "connectors" },
{ "name": "displayport", "category": "connectors" },
{ "name": "dvi", "category": "connectors" },
{ "name": "eps", "category": "connectors" },
{ "name": "floppy", "category": "connectors" },
{ "name": "hdmi", "category": "connectors" },
{ "name": "ide", "category": "connectors" },
{ "name": "jack", "category": "connectors" },
{ "name": "m.2", "category": "connectors" },
{ "name": "molex", "category": "connectors" },
{ "name": "p4", "category": "connectors" },
{ "name": "pcie", "category": "connectors" },
{ "name": "qsfp", "category": "connectors" },
{ "name": "qsfp+", "category": "connectors" },
{ "name": "qsfp28", "category": "connectors" },
{ "name": "rj11", "category": "connectors" },
{ "name": "rj45", "category": "connectors" },
{ "name": "sas", "category": "connectors" },
{ "name": "sata", "category": "connectors" },
{ "name": "scsi", "category": "connectors" },
{ "name": "sfp", "category": "connectors" },
{ "name": "sfp+", "category": "connectors" },
{ "name": "sfp28", "category": "connectors" },
{ "name": "toslink", "category": "connectors" },
{ "name": "vga", "category": "connectors" },
{ "name": "xlr", "category": "connectors" }
]
}

View file

@ -0,0 +1,30 @@
{
"depends": [ "git:base" ],
"categories": [
{ "name": "screws", "parent": "hardware"}
],
"tags": [
{"name": "m1", "category": "screws"},
{"name": "m2", "category": "screws"},
{"name": "m2.5", "category": "screws"},
{"name": "m3", "category": "screws"},
{"name": "m4", "category": "screws"},
{"name": "m5", "category": "screws"},
{"name": "m6", "category": "screws"},
{"name": "m8", "category": "screws"},
{"name": "m10", "category": "screws"},
{"name": "m12", "category": "screws"},
{"name": "m16", "category": "screws"},
{"name": "torx", "category": "screws"},
{"name": "hex", "category": "screws"},
{"name": "phillips", "category": "screws"},
{"name": "pozidriv", "category": "screws"},
{"name": "slotted", "category": "screws"},
{"name": "socket", "category": "screws"},
{"name": "flat", "category": "screws"},
{"name": "pan", "category": "screws"},
{"name": "button", "category": "screws"},
{"name": "countersunk", "category": "screws"},
{"name": "round", "category": "screws"}
]
}

View file

@ -0,0 +1,68 @@
{
"depends": ["git:base"],
"categories": [
{ "name": "powertools", "parent": "tools" }
],
"tags": [
{ "name": "3d printer"},
{ "name": "air compressor", "category": "powertools"},
{ "name": "air filter"},
{ "name": "automotive", "category": "tools" },
{ "name": "bandsaw"},
{ "name": "belt sander"},
{ "name": "bench grinder"},
{ "name": "circular saw", "category": "powertools"},
{ "name": "concrete", "category": "tools" },
{ "name": "construction", "category": "tools" },
{ "name": "corded", "category": "powertools" },
{ "name": "cordless", "category": "powertools" },
{ "name": "disc sander"},
{ "name": "drill press"},
{ "name": "drywall", "category": "tools" },
{ "name": "dust collector"},
{ "name": "dust mask"},
{ "name": "ear protection"},
{ "name": "electrical", "category": "tools" },
{ "name": "eye protection"},
{ "name": "fire extinguisher"},
{ "name": "first aid"},
{ "name": "generator", "category": "powertools"},
{ "name": "glue gun", "category": "powertools"},
{ "name": "grinder", "category": "powertools"},
{ "name": "hammer drill", "category": "powertools"},
{ "name": "handheld", "category": "tools" },
{ "name": "heat gun", "category": "powertools"},
{ "name": "impact driver", "category": "powertools"},
{ "name": "jigsaw"},
{ "name": "jointer"},
{ "name": "ladder"},
{ "name": "laser cutter"},
{ "name": "lathe"},
{ "name": "masonry", "category": "tools" },
{ "name": "mechanical", "category": "tools" },
{ "name": "metalworking", "category": "tools" },
{ "name": "mill"},
{ "name": "multitool"},
{ "name": "nailgun", "category": "powertools"},
{ "name": "oscillating tool", "category": "powertools"},
{ "name": "painting", "category": "tools" },
{ "name": "planer"},
{ "name": "plasma cutter"},
{ "name": "plumbing", "category": "tools" },
{ "name": "powerplane", "category": "powertools"},
{ "name": "pressure washer", "category": "powertools"},
{ "name": "roofing", "category": "tools" },
{ "name": "router table"},
{ "name": "router", "category": "powertools"},
{ "name": "sawhorse"},
{ "name": "sawzall", "category": "powertools"},
{ "name": "screwgun", "category": "powertools"},
{ "name": "stationary", "category": "tools" },
{ "name": "tablesaw"},
{ "name": "tile", "category": "tools" },
{ "name": "welder", "category": "powertools"},
{ "name": "woodworking", "category": "tools" },
{ "name": "workbench"},
{ "name": "worklight"}
]
}

View file

@ -1,11 +1,39 @@
from django.contrib import admin
from toolshed.models import InventoryItem, Property, Tag, Category
from toolshed.models import (
InventoryItem, ItemProperty, ItemTag, Property, Tag, Category, StorageLocation, WorkflowInstance,
)
class ItemTagInline(admin.TabularInline):
model = ItemTag
extra = 0
autocomplete_fields = ('tag',)
class ItemPropertyInline(admin.TabularInline):
model = ItemProperty
extra = 0
autocomplete_fields = ('property',)
class InventoryItemAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
search_fields = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
'owner_group', 'storage_location', 'get_tags', 'get_properties')
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name')
inlines = (ItemTagInline, ItemPropertyInline)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags', 'itemproperty_set__property')
@admin.display(description='Tags')
def get_tags(self, obj):
return ', '.join(tag.name for tag in obj.tags.all())
@admin.display(description='Properties')
def get_properties(self, obj):
return ', '.join(f'{ip.property.name}={ip.value}' for ip in obj.itemproperty_set.all())
admin.site.register(InventoryItem, InventoryItemAdmin)
@ -13,7 +41,7 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
class PropertyAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
search_fields = ('name', 'description', 'category__name', 'unit_symbol', 'origin')
admin.site.register(Property, PropertyAdmin)
@ -21,7 +49,7 @@ admin.site.register(Property, PropertyAdmin)
class TagAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'origin')
search_fields = ('name', 'description', 'category', 'origin')
search_fields = ('name', 'description', 'category__name', 'origin')
admin.site.register(Tag, TagAdmin)
@ -29,7 +57,26 @@ admin.site.register(Tag, TagAdmin)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'parent', 'origin')
search_fields = ('name', 'description', 'parent', 'origin')
search_fields = ('name', 'description', 'parent__name', 'origin')
admin.site.register(Category, CategoryAdmin)
class StorageLocationAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'parent', 'owner')
search_fields = ('name', 'description', 'category__name', 'parent__name', 'owner__username')
list_filter = ('category', 'owner')
admin.site.register(StorageLocation, StorageLocationAdmin)
class WorkflowInstanceAdmin(admin.ModelAdmin):
list_display = ('slug', 'state', 'owner', 'created_at', 'updated_at')
search_fields = ('slug', 'owner__username')
list_filter = ('state', 'created_at', 'owner')
readonly_fields = ('created_at', 'updated_at')
admin.site.register(WorkflowInstance, WorkflowInstanceAdmin)

View file

@ -4,10 +4,29 @@ from rest_framework.decorators import api_view, permission_classes, authenticati
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthenticationLocal
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal
from files.models import File
from files.serializers import FileSerializer
from toolshed.models import InventoryItem
from toolshed.models import InventoryItem, WorkflowInstance
def _get_authorized_item(identity, item_id):
"""Look up an item by its owner-scoped id and confirm identity may act on it - either as its
personal owner (requires a local ToolshedUser account) or as a current member of its owning
group (works for a remote member too, since group membership is identity-level, see
docs/design-in-progress/groups-mvp.md). id is only unique within one owner/group's own items,
so the lookup itself must be scoped rather than a bare global get. Returns None if not found
or not authorized, the same shape InventoryItem.DoesNotExist handling around it already
expects."""
if identity.user.exists():
try:
return InventoryItem.objects.get(owner=identity.user.get(), id=item_id)
except InventoryItem.DoesNotExist:
pass
try:
return InventoryItem.objects.get(owner_group__in=identity.member_of_groups.all(), id=item_id)
except InventoryItem.DoesNotExist:
return None
@api_view(['GET'])
@ -19,30 +38,61 @@ def list_all_files(request, format=None): # /files/
def get_item_files(request, item_id):
try:
item = InventoryItem.objects.get(id=item_id, owner=request.user)
files = item.files.all()
return Response(FileSerializer(files, many=True).data)
except InventoryItem.DoesNotExist:
item = _get_authorized_item(request.user, item_id)
if item is None:
return Response(status=status.HTTP_404_NOT_FOUND)
files = item.files.all()
return Response(FileSerializer(files, many=True).data)
def post_item_file(request, item_id):
item = _get_authorized_item(request.user, item_id)
if item is None:
return Response(status=status.HTTP_404_NOT_FOUND)
if 'file_hash' in request.data:
# Attaches an already-staged file by hash instead of re-uploading it. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
if not request.user.user.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = File.objects.get(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get())
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
item.files.add(file)
return Response(FileSerializer(file).data, status=status.HTTP_201_CREATED)
serializer = FileSerializer(data=request.data)
if serializer.is_valid():
file = serializer.save()
item.files.add(file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_staged_files(request, workflow_id):
try:
item = InventoryItem.objects.get(id=item_id, owner=request.user)
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
# Hash alone is enough to discover what another session/device already staged. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
return Response(list(workflow.staged_files.values_list('hash', flat=True)))
except WorkflowInstance.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
def post_staged_file(request, workflow_id):
try:
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
serializer = FileSerializer(data=request.data)
if serializer.is_valid():
file = serializer.save()
item.files.add(file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
workflow.staged_files.add(file)
return Response({'hash': file.hash}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except InventoryItem.DoesNotExist:
except WorkflowInstance.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@api_view(['POST', 'GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
@authentication_classes([SignatureAuthentication])
def item_files(request, item_id, format=None): # /item_files/
if request.method == 'GET':
return get_item_files(request, item_id)
@ -52,16 +102,47 @@ def item_files(request, item_id, format=None): # /item_files/
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
@authentication_classes([SignatureAuthentication])
def delete_item_file(request, item_id, file_id, format=None): # /item_files/
item = _get_authorized_item(request.user, item_id)
if item is None:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
item = InventoryItem.objects.get(id=item_id, owner=request.user)
file = item.files.get(id=file_id)
item.files.remove(file_id)
if file.connected_items.count() == 0:
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
item.files.remove(file_id)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST', 'GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def staged_files(request, workflow_id, format=None): # /staged_files/
if request.method == 'GET':
return get_staged_files(request, workflow_id)
elif request.method == 'POST':
return post_staged_file(request, workflow_id)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged_files/
try:
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
file = workflow.staged_files.get(hash=file_hash)
workflow.staged_files.remove(file)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InventoryItem.DoesNotExist:
except WorkflowInstance.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@ -71,4 +152,6 @@ urlpatterns = [
path('files/', list_all_files),
path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files),
path('staged_files/<int:workflow_id>/<str:file_hash>/', delete_staged_file),
]

View file

@ -0,0 +1,209 @@
import secrets
from django.urls import path
from rest_framework import status
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSetMixin
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity, \
ToolshedUser
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
verify_incoming_group_invite
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
class Groups(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/groups/
groups = request.user.member_of_groups.all()
return Response(GroupSerializer(groups, many=True).data)
def post(self, request, format=None): # /api/groups/
name = request.data.get('name')
if not name:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
if Group.objects.filter(name=name, domain=request.user.domain).exists():
return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'})
group = Group.objects.create(name=name, domain=request.user.domain)
group.members.add(request.user)
return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data)
class GroupDetail(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, handle, format=None): # /api/groups/<name@domain>/
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(GroupSerializer(group).data)
@api_view(['DELETE'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def removeGroupMember(request, handle, identity_id, format=None): # /api/groups/<name@domain>/members/<identity_id>/
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
member = get_object_or_404(group.members, pk=identity_id)
if group.members.count() <= 1:
return Response(status=status.HTTP_400_BAD_REQUEST,
data={'status': "cannot remove the group's last member"})
group.members.remove(member)
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def createGroupInvite(request, handle, format=None): # /api/groups/<name@domain>/invites/
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
invitee = request.data.get('invitee')
if not invitee:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'})
try:
invitee_username, invitee_domain = split_userhandle_or_throw(invitee)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'})
if group.members.filter(username=invitee_username, domain=invitee_domain).exists():
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'})
secret = secrets.token_hex(64)
GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain,
secret=secret)
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'})
class GroupInvitesIncoming(APIView, ViewSetMixin):
"""/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery
endpoint an inviter's client posts to directly on the invitee's own home backend (see
docs/design-in-progress/groups-mvp.md's invite/accept dance)."""
def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites
raw_request = request.body.decode('utf-8')
if not (user := authenticate_request_against_local_users(request, raw_request)):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
invites = user.group_invites_incoming.all()
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
def post(self, request, format=None): # /api/groupinvites/ - delivery, self-certified, caller isn't known here
raw_request = request.body.decode('utf-8')
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain)
except ToolshedUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'})
GroupInviteIncoming.objects.create(
group_name=group_name, group_domain=group_domain,
inviter_username=inviter_username, inviter_domain=inviter_domain,
invitee_user=invitee_user, secret=request.data['secret'])
return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'})
@api_view(['DELETE'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/accept/
"""Local bookkeeping call on the invitee's own home backend, made by the client once its
accept request against the group's home backend (acceptGroupInvite) has succeeded. Turns the
now-obsolete GroupInviteIncoming into a durable GroupMembership pointer, so this backend
remembers which foreign groups its own user belongs to -- the same way FriendRequestIncoming
ends up recorded as a friend on both sides, not just the group's authoritative backend."""
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
membership, _ = GroupMembership.objects.get_or_create(
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
class GroupMemberships(APIView, ViewSetMixin):
"""/api/groupmemberships/ - the personal index of every group (local or remote) this backend
has recorded the caller as belonging to, kept independently of the group's own membership
roster (see GroupMembership and recordGroupMembership)."""
authentication_classes = [SignatureAuthenticationLocal]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/groupmemberships/
memberships = request.user.group_memberships.all()
return Response(GroupMembershipSerializer(memberships, many=True).data)
@api_view(['POST'])
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
raw_request = request.body.decode('utf-8')
for field in ('group', 'invitee', 'invitee_key', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
group = Group.objects.get(name=group_name, domain=group_domain)
except Group.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'})
invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username,
invitee_domain=invitee_domain, secret=request.data['secret'])
if not invite.exists():
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'})
identity, _ = KnownIdentity.objects.get_or_create(
username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key'])
group.members.add(identity)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'})
urlpatterns = [
path('groups/', Groups.as_view(), name='groups'),
path('groups/<str:handle>/', GroupDetail.as_view(), name='group_detail'),
path('groups/<str:handle>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<str:handle>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
]

View file

@ -0,0 +1,28 @@
from django.urls import path
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSetMixin
from authentication.models import KnownIdentity
from authentication.signature_auth import SignatureAuthentication
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
class IdMap(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/idmap/
identity = request.user
identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk)
groups = identity.member_of_groups.all()
return Response({
'identities': FriendSerializer(identities, many=True).data,
'groups': GroupIdMapSerializer(groups, many=True).data,
})
urlpatterns = [
path('idmap/', IdMap.as_view(), name='idmap'),
]

View file

@ -7,14 +7,14 @@ from hostadmin.models import Domain
from authentication.signature_auth import SignatureAuthentication
from toolshed.models import Tag, Property, Category, InventoryItem
from toolshed.serializers import CategorySerializer, PropertySerializer
from backend.settings import TOOLSHED_VERSION
from backend.settings import TOOLSHED_VERSION, GIT_COMMIT
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def get_version(request, format=None): # /version/
return Response({'version': TOOLSHED_VERSION})
return Response({'version': TOOLSHED_VERSION, 'commit': GIT_COMMIT})
@api_view(['GET'])

View file

@ -1,18 +1,35 @@
from django.db import transaction
from django.urls import path
from rest_framework import routers, viewsets
from rest_framework.decorators import authentication_classes, api_view, permission_classes
from rest_framework import routers, viewsets, status
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity
from authentication.signature_auth import SignatureAuthentication
from toolshed.models import InventoryItem, StorageLocation
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
from authentication.models import ToolshedUser, KnownIdentity, Group
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
from files.models import File
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
router = routers.SimpleRouter()
def resolve_group_by_handle(handle):
"""handle is "name@domain" (no leading '#') - the same format/parser group.py's GroupDetail
uses, so a group reference parses identically everywhere it appears (URL path, ?group= query
param, or an owner_group payload field), rather than some spots taking a handle and others a
bare pk."""
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return None
try:
return Group.objects.get(name=name, domain=domain)
except Group.DoesNotExist:
return None
def inventory_items(identity):
try:
user = identity.user.get()
@ -22,7 +39,8 @@ def inventory_items(identity):
except ToolshedUser.DoesNotExist:
pass
for friend in identity.friends.all():
if friend_user := friend.user.get():
friend_user = friend.user.first()
if friend_user:
for item in friend_user.inventory_items.all():
if item.availability_policy != 'private':
yield item
@ -32,50 +50,207 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
serializer_class = InventoryItemSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
# Detail routes address an item by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
return InventoryItem.objects.filter(owner=self.request.user.user.get())
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
# personal items require .user.exists(). See
# docs/implementation.md#group-member-identities-without-local-accounts.
if type(self.request.user) != KnownIdentity:
return InventoryItem.objects.none()
identity = self.request.user
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
if self.action != 'list':
# retrieve/update/destroy: any item the caller may act on, own or group. See
# docs/implementation.md#inventory-queryset-scope-by-action.
if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
return group_items
group_handle = self.request.query_params.get('group')
if group_handle:
group = resolve_group_by_handle(group_handle)
if not group or not group.is_member(identity):
return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=group)
if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get())
return InventoryItem.objects.none()
def perform_create(self, serializer):
group_handle = self.request.data.get('owner_group')
with transaction.atomic():
serializer.save(owner=self.request.user.user.get()).clean()
if group_handle:
group = resolve_group_by_handle(group_handle)
if not group:
raise NotFound('No such group')
if not group.is_member(self.request.user):
raise PermissionDenied('Not a member of this group')
serializer.save(owner=None, owner_group=group).clean()
else:
serializer.save(owner=self.request.user.user.get()).clean()
@staticmethod
def _is_authorized(request, instance):
if instance.owner_id:
return request.user.user.filter(pk=instance.owner_id).exists()
return instance.owner_group.is_member(request.user)
def perform_update(self, serializer):
with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get():
if self._is_authorized(self.request, serializer.instance):
serializer.save().clean()
def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get():
if self._is_authorized(self.request, instance):
instance.delete()
def matches_query(item, query):
query = query.lower()
if query in item.name.lower():
return True
if item.description and query in item.description.lower():
return True
if any(query in tag.name.lower() for tag in item.tags.all()):
return True
return False
@api_view(['GET'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def search_inventory_items(request):
query = request.query_params.get('query')
if query:
return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data)
matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)]
return Response(InventoryItemSerializer(matching_items, many=True).data)
return Response({'error': 'No query provided.'}, status=400)
@api_view(['GET'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def get_shared_item(request, handle, id):
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=400)
try:
owner = ToolshedUser.objects.get(username=username, domain=domain)
except ToolshedUser.DoesNotExist:
return Response(status=404)
if owner not in request.user.friends_or_self():
return Response(status=403)
try:
item = owner.inventory_items.get(id=id)
except InventoryItem.DoesNotExist:
return Response(status=404)
is_owner = request.user.user.filter(pk=owner.pk).exists()
if item.availability_policy == 'private' and not is_owner:
return Response(status=403)
return Response(InventoryItemSerializer(item).data)
class StorageLocationViewSet(viewsets.ModelViewSet):
serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
# Detail routes address a location by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self):
# Mirrors InventoryItemViewSet.get_queryset() - see its own comments for why the
# list/detail scopes differ and why group membership alone (no linked ToolshedUser
# required) is enough for the group branch.
if type(self.request.user) != KnownIdentity:
return StorageLocation.objects.none()
identity = self.request.user
group_locations = StorageLocation.objects.filter(owner_group__in=identity.member_of_groups.all())
if self.action != 'list':
if identity.user.exists():
return StorageLocation.objects.filter(owner=identity.user.get()) | group_locations
return group_locations
group_handle = self.request.query_params.get('group')
if group_handle:
group = resolve_group_by_handle(group_handle)
if not group or not group.is_member(identity):
return StorageLocation.objects.none()
return StorageLocation.objects.filter(owner_group=group)
if identity.user.exists():
return StorageLocation.objects.filter(owner=identity.user.get())
return StorageLocation.objects.none()
def perform_create(self, serializer):
group_handle = self.request.data.get('owner_group')
with transaction.atomic():
if group_handle:
group = resolve_group_by_handle(group_handle)
if not group:
raise NotFound('No such group')
if not group.is_member(self.request.user):
raise PermissionDenied('Not a member of this group')
serializer.save(owner=None, owner_group=group).clean()
else:
serializer.save(owner=self.request.user.user.get()).clean()
@staticmethod
def _is_authorized(request, instance):
if instance.owner_id:
return request.user.user.filter(pk=instance.owner_id).exists()
return instance.owner_group.is_member(request.user)
def perform_update(self, serializer):
with transaction.atomic():
if self._is_authorized(self.request, serializer.instance):
serializer.save().clean()
def perform_destroy(self, instance):
if self._is_authorized(self.request, instance):
instance.delete()
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
serializer_class = WorkflowInstanceSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
return StorageLocation.objects.filter(owner=self.request.user.user.get())
return StorageLocation.objects.none()
return WorkflowInstance.objects.filter(owner=self.request.user.user.get())
return WorkflowInstance.objects.none()
def perform_create(self, serializer):
with transaction.atomic():
serializer.save(owner=self.request.user.user.get())
def perform_update(self, serializer):
with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get():
serializer.save()
def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get():
staged_file_ids = list(instance.staged_files.values_list('id', flat=True))
instance.delete()
for file in File.objects.filter(id__in=staged_file_ids):
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete()
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
urlpatterns = router.urls + [
path('search/', search_inventory_items, name='search_inventory_items'),
path('inventory_items/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
]

View file

@ -0,0 +1,253 @@
from django.http import HttpResponse
from django.urls import path
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication
from toolshed.offlinedata import (
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
import_locations, import_friends, import_inventory,
profile_data, profile_picture_files, settings_data, import_profile, import_settings,
delete_user_data, delete_user_account,
)
def local_user_or_none(identity):
"""Resolve the local ToolshedUser associated with an authenticated KnownIdentity, if any.
Returns None if the identity belongs to an external/remote user with no local account.
"""
if identity is None:
return None
return identity.user.first()
def user_data(user):
import io
import json
import zipfile
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
zip_file.writestr('profile.json', json.dumps(profile_data(user)))
zip_file.writestr('settings.json', json.dumps(settings_data(user)))
inventory_csv = b''.join(rows_to_csv(inventory_rows(user)))
zip_file.writestr('inventory.csv', inventory_csv)
friends_csv = b''.join(rows_to_csv(friend_rows(user)))
zip_file.writestr('friends.csv', friends_csv)
locations_csv = b''.join(rows_to_csv(location_rows(user)))
zip_file.writestr('locations.csv', locations_csv)
written_files = set()
for arcname, data in profile_picture_files(user):
zip_file.writestr(arcname, data)
written_files.add(arcname)
for arcname, data in inventory_files(user):
if arcname in written_files:
continue
zip_file.writestr(arcname, data)
written_files.add(arcname)
return zip_buffer.getvalue()
def parse_user_data(data):
import io
import zipfile
with zipfile.ZipFile(io.BytesIO(data), "r") as zip_file:
for file_name in zip_file.namelist():
yield file_name, zip_file.read(file_name)
def import_files(zip_file):
"""Fault-tolerant extraction of the 'files/' subfolder into File objects.
Returns a dict mapping the zip arcname to the created/existing File instance. Entries that
fail to read or save are silently skipped so a single corrupt attachment doesn't abort the import.
"""
from hashlib import sha256
import mimetypes
from django.core.files.base import ContentFile
from django.db import transaction
from files.models import File
result = {}
for name in zip_file.namelist():
if not name.startswith('files/') or name == 'files/':
continue
try:
with transaction.atomic():
data = zip_file.read(name)
content_hash = sha256(data).hexdigest()
mime_type, _ = mimetypes.guess_type(name)
file_obj = File.objects.filter(hash=content_hash).first()
if file_obj is None:
file_obj = File.objects.create(
file=ContentFile(data, content_hash),
mime_type=mime_type or 'application/octet-stream',
hash=content_hash,
)
result[name] = file_obj
except Exception as error:
print(f'Skipping file "{name}" during import: {error}')
return result
def import_user_data(user, data):
"""Fault-tolerant import of an export zip produced by `user_data()`.
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
the 'files/' subfolder may be missing; whatever is present is imported and everything else
is silently skipped.
Each section is imported inside its own transaction savepoint (`transaction.atomic()`), so a
DB-level failure in one section (e.g. a profile.json whose email collides with another
account) can't leave the connection in a broken/aborted-transaction state that would
otherwise silently take down every subsequent section (including the inventory items and
their properties) with an opaque "current transaction is aborted" error.
"""
import io
import zipfile
from django.db import transaction
summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0,
'errors': []}
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
names = set(zip_file.namelist())
available_files = import_files(zip_file)
summary['files'] = len(available_files)
if 'profile.json' in names:
try:
with transaction.atomic():
summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
except Exception as error:
summary['errors'].append(f'Could not import profile.json: {error}')
if 'settings.json' in names:
try:
with transaction.atomic():
summary['settings'] = import_settings(user, zip_file.read('settings.json'))
except Exception as error:
summary['errors'].append(f'Could not import settings.json: {error}')
if 'locations.csv' in names:
try:
with transaction.atomic():
summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
except Exception as error:
summary['errors'].append(f'Could not import locations.csv: {error}')
if 'friends.csv' in names:
try:
with transaction.atomic():
summary['friends'] = import_friends(user, zip_file.read('friends.csv'))
except Exception as error:
summary['errors'].append(f'Could not import friends.csv: {error}')
if 'inventory.csv' in names:
try:
with transaction.atomic():
summary['inventory_items'], inventory_errors = import_inventory(
user, zip_file.read('inventory.csv'), available_files)
summary['errors'].extend(inventory_errors)
except Exception as error:
summary['errors'].append(f'Could not import inventory.csv: {error}')
return summary
def _extract_zip_bytes(zip_payload):
"""Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes)."""
import base64
if hasattr(zip_payload, 'read'):
return zip_payload.read()
if isinstance(zip_payload, str):
try:
return base64.b64decode(zip_payload, validate=True)
except Exception:
return zip_payload.encode('utf-8')
return zip_payload
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def import_data(request, format=None):
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
zip_payload = request.data.get('zip')
if not zip_payload:
return Response(status=400)
try:
zip_bytes = _extract_zip_bytes(zip_payload)
summary = import_user_data(local_user, zip_bytes)
except Exception as error:
return Response({'detail': f'Could not read zip file: {error}'}, status=400)
return Response(summary, status=200)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def export_data(request, format=None):
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
return HttpResponse(user_data(local_user), content_type='application/zip', status=200)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def delete_data(request, format=None):
"""Wipe all of the local user's data (everything included in the export), but keep the account.
This is *not* account deletion/closure - the user stays logged in and can keep using the
account (with all their data reset to a blank slate) afterwards.
"""
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
summary = delete_user_data(local_user)
return Response(summary, status=200)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def delete_account(request, format=None):
"""Permanently close the local user's account, after wiping all of its data.
Unlike `delete_data()`, this also removes the account itself - the user can no longer log
in afterwards. Their public identity is kept so remote friends/history referencing it stay
intact, but the local ToolshedUser row is gone.
"""
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
summary = delete_user_account(local_user)
return Response(summary, status=200)
urlpatterns = [
path('export/', export_data, name='export_data'),
path('import/', import_data, name='import_data'),
path('account_data/', delete_data, name='delete_data'),
path('account/', delete_account, name='delete_account'),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.2 on 2025-09-26 10:20
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('toolshed', '0006_alter_tag_options_alter_category_name_and_more'),
]
operations = [
migrations.CreateModel(
name='WorkflowInstance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('state', models.CharField(max_length=255)),
('payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflows', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.2 on 2026-07-23 02:02
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0007_workflowinstance'),
]
operations = [
migrations.AlterField(
model_name='inventoryitem',
name='storage_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'),
),
]

View file

@ -0,0 +1,21 @@
# Generated manually: WorkflowInstance.payload changes from a native JSONField
# to a plain opaque TextField. The frontend now serializes/deserializes the
# JSON itself; the backend just stores whatever string it receives.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0008_alter_inventoryitem_storage_location'),
]
operations = [
migrations.AlterField(
model_name='workflowinstance',
name='payload',
field=models.TextField(blank=True, default=''),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2026-08-09 13:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0009_alter_workflowinstance_payload'),
]
operations = [
migrations.RenameField(
model_name='workflowinstance',
old_name='name',
new_name='slug',
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.2
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0001_initial'),
('toolshed', '0010_rename_name_workflowinstance_slug'),
]
operations = [
migrations.AddField(
model_name='workflowinstance',
name='staged_files',
field=models.ManyToManyField(blank=True, related_name='staged_by_workflows', to='files.file'),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
('toolshed', '0011_workflowinstance_staged_files'),
]
operations = [
migrations.AddField(
model_name='inventoryitem',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='authentication.group'),
),
migrations.AlterField(
model_name='inventoryitem',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,37 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
('toolshed', '0012_inventoryitem_owner_group_alter_inventoryitem_owner'),
]
operations = [
migrations.CreateModel(
name='OwnerItemSequence',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_id', models.PositiveIntegerField(default=0)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='+', to=settings.AUTH_USER_MODEL)),
('owner_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='+', to='authentication.group')),
],
),
migrations.AddConstraint(
model_name='owneritemsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner__isnull', False)), fields=('owner',),
name='owneritemsequence_unique_owner'),
),
migrations.AddConstraint(
model_name='owneritemsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner_group__isnull', False)),
fields=('owner_group',),
name='owneritemsequence_unique_owner_group'),
),
]

View file

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0013_owneritemsequence'),
]
operations = [
migrations.RenameField(
model_name='inventoryitem',
old_name='id',
new_name='internal_id',
),
migrations.AlterField(
model_name='inventoryitem',
name='internal_id',
field=models.AutoField(primary_key=True, serialize=False),
),
]

View file

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0014_rename_id_inventoryitem_internal_id'),
]
operations = [
migrations.AddField(
model_name='inventoryitem',
name='id',
field=models.PositiveIntegerField(editable=False, null=True),
),
]

View file

@ -0,0 +1,42 @@
from django.db import migrations
def collapse_ids(apps, schema_editor):
"""Collapse each owner/owner_group's InventoryItem ids from the sparse global range they
had before this migration down to a continuous 1..N range, in original creation order
(internal_id order), including soft-deleted rows since they still occupy a slot in that
scope's history. Then seed OwnerItemSequence so future allocation continues right after."""
InventoryItem = apps.get_model('toolshed', 'InventoryItem')
OwnerItemSequence = apps.get_model('toolshed', 'OwnerItemSequence')
scope = None
next_id = 0
counts = {}
for item in InventoryItem.objects.order_by('owner_id', 'owner_group_id', 'internal_id'):
key = (item.owner_id, item.owner_group_id)
if key != scope:
scope = key
next_id = 0
next_id += 1
item.id = next_id
item.save(update_fields=['id'])
counts[key] = next_id
for (owner_id, owner_group_id), count in counts.items():
OwnerItemSequence.objects.update_or_create(
owner_id=owner_id, owner_group_id=owner_group_id, defaults={'last_id': count})
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0015_inventoryitem_id'),
]
operations = [
migrations.RunPython(collapse_ids, noop_reverse),
]

View file

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0016_backfill_inventoryitem_id'),
]
operations = [
migrations.AlterField(
model_name='inventoryitem',
name='id',
field=models.PositiveIntegerField(editable=False),
),
migrations.AddConstraint(
model_name='inventoryitem',
constraint=models.UniqueConstraint(fields=('owner', 'owner_group', 'id'),
name='inventoryitem_unique_owner_scoped_id'),
),
]

View file

@ -0,0 +1,23 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('toolshed', '0017_inventoryitem_id_not_null_and_unique'),
]
operations = [
migrations.CreateModel(
name='OwnerStorageLocationSequence',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_id', models.PositiveIntegerField(default=0)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+',
to=settings.AUTH_USER_MODEL, unique=True)),
],
),
]

View file

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0018_ownerstoragelocationsequence'),
]
operations = [
migrations.RenameField(
model_name='storagelocation',
old_name='id',
new_name='internal_id',
),
migrations.AlterField(
model_name='storagelocation',
name='internal_id',
field=models.AutoField(primary_key=True, serialize=False),
),
]

View file

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0019_rename_id_storagelocation_internal_id'),
]
operations = [
migrations.AddField(
model_name='storagelocation',
name='id',
field=models.PositiveIntegerField(editable=False, null=True),
),
]

View file

@ -0,0 +1,40 @@
from django.db import migrations
def collapse_ids(apps, schema_editor):
"""Collapse each owner's StorageLocation ids from the sparse global range they had before this
migration down to a continuous 1..N range, in original creation order (internal_id order). Then
seed OwnerStorageLocationSequence so future allocation continues right after."""
StorageLocation = apps.get_model('toolshed', 'StorageLocation')
OwnerStorageLocationSequence = apps.get_model('toolshed', 'OwnerStorageLocationSequence')
scope = None
next_id = 0
counts = {}
for location in StorageLocation.objects.order_by('owner_id', 'internal_id'):
key = location.owner_id
if key != scope:
scope = key
next_id = 0
next_id += 1
location.id = next_id
location.save(update_fields=['id'])
counts[key] = next_id
for owner_id, count in counts.items():
OwnerStorageLocationSequence.objects.update_or_create(owner_id=owner_id, defaults={'last_id': count})
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0020_storagelocation_id'),
]
operations = [
migrations.RunPython(collapse_ids, noop_reverse),
]

View file

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0021_backfill_storagelocation_id'),
]
operations = [
migrations.AlterField(
model_name='storagelocation',
name='id',
field=models.PositiveIntegerField(editable=False),
),
migrations.AddConstraint(
model_name='storagelocation',
constraint=models.UniqueConstraint(fields=('owner', 'id'), name='storagelocation_unique_owner_scoped_id'),
),
]

View file

@ -0,0 +1,63 @@
# Generated by Django 4.2.2 on 2026-08-26 18:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0006_groupmembership'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('toolshed', '0022_storagelocation_id_not_null_and_unique'),
]
operations = [
migrations.RemoveConstraint(
model_name='storagelocation',
name='storagelocation_unique_owner_scoped_id',
),
migrations.AddField(
model_name='ownerstoragelocationsequence',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='authentication.group'),
),
migrations.AddField(
model_name='storagelocation',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to='authentication.group'),
),
migrations.AlterField(
model_name='owneritemsequence',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ownerstoragelocationsequence',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ownerstoragelocationsequence',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='storagelocation',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='ownerstoragelocationsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner__isnull', False)), fields=('owner',), name='ownerstoragelocationsequence_unique_owner'),
),
migrations.AddConstraint(
model_name='ownerstoragelocationsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner_group__isnull', False)), fields=('owner_group',), name='ownerstoragelocationsequence_unique_owner_group'),
),
migrations.AddConstraint(
model_name='storagelocation',
constraint=models.UniqueConstraint(fields=('owner', 'owner_group', 'id'), name='storagelocation_unique_owner_scoped_id'),
),
]

View file

@ -1,9 +1,9 @@
from django.db import models
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.core.validators import MinValueValidator, MaxValueValidator
from django_softdelete.models import SoftDeleteModel
from rest_framework.exceptions import ValidationError
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from files.models import File
@ -26,6 +26,10 @@ class Category(SoftDeleteModel):
parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:base#category:tools'"""
return f"{self.origin}#category:{self.name}"
class Property(models.Model):
name = models.CharField(max_length=255)
@ -50,6 +54,10 @@ class Property(models.Model):
def __str__(self):
return self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:base#property:length'"""
return f"{self.origin}#property:{self.name}"
class Tag(models.Model):
name = models.CharField(max_length=255)
@ -69,6 +77,35 @@ class Tag(models.Model):
def __str__(self):
return self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:tools#tag:drill'"""
return f"{self.origin}#tag:{self.name}"
class OwnerItemSequence(models.Model):
"""Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential,
gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is
set, mirroring InventoryItem's own split."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
last_id = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner'], condition=models.Q(owner__isnull=False),
name='owneritemsequence_unique_owner'),
models.UniqueConstraint(fields=['owner_group'], condition=models.Q(owner_group__isnull=False),
name='owneritemsequence_unique_owner_group'),
]
@classmethod
def allocate(cls, *, owner=None, owner_group=None):
with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner, owner_group=owner_group)
seq.last_id += 1
seq.save(update_fields=['last_id'])
return seq.last_id
class InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = (
@ -79,23 +116,46 @@ class InventoryItem(SoftDeleteModel):
('private', 'Private'),
)
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within owner/owner_group's own items (see
# OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create().
id = models.PositiveIntegerField(editable=False)
published = models.BooleanField(default=False)
name = models.CharField(max_length=255, null=True, blank=True)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
created_at = models.DateTimeField(auto_now_add=True)
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
properties = models.ManyToManyField(Property, through='ItemProperty')
files = models.ManyToManyField(File, related_name='connected_items')
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True,
storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
related_name='inventory_items')
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'owner_group', 'id'],
name='inventoryitem_unique_owner_scoped_id'),
]
def clean(self):
if (self.name is None or self.name == "") and self.files.count() == 0:
raise ValidationError("Name or at least one file must be set")
if (self.owner is None) == (self.owner_group is None):
raise ValidationError("Exactly one of owner or owner_group must be set")
@classmethod
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
"""The only supported way to create an InventoryItem: atomically allocates the next id
for this owner/owner_group scope."""
with transaction.atomic():
next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group)
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
class ItemProperty(models.Model):
@ -109,14 +169,80 @@ class ItemTag(models.Model):
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
class OwnerStorageLocationSequence(models.Model):
"""Tracks the last StorageLocation id handed out per owner/owner_group scope for sequential,
gapless allocation (see StorageLocation.create_for_owner); exactly one of owner/owner_group is
set, mirroring OwnerItemSequence/InventoryItem's own split."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
last_id = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner'], condition=models.Q(owner__isnull=False),
name='ownerstoragelocationsequence_unique_owner'),
models.UniqueConstraint(fields=['owner_group'], condition=models.Q(owner_group__isnull=False),
name='ownerstoragelocationsequence_unique_owner_group'),
]
@classmethod
def allocate(cls, *, owner=None, owner_group=None):
with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner, owner_group=owner_group)
seq.last_id += 1
seq.save(update_fields=['last_id'])
return seq.last_id
class StorageLocation(models.Model):
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner/owner_group's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
# .objects.create().
id = models.PositiveIntegerField(editable=False)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'owner_group', 'id'],
name='storagelocation_unique_owner_scoped_id'),
]
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name
def clean(self):
if (self.owner is None) == (self.owner_group is None):
raise ValidationError("Exactly one of owner or owner_group must be set")
@classmethod
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
"""The only supported way to create a StorageLocation: atomically allocates the next id
for this owner/owner_group's scope."""
with transaction.atomic():
next_id = OwnerStorageLocationSequence.allocate(owner=owner, owner_group=owner_group)
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
class WorkflowInstance(models.Model):
slug = models.CharField(max_length=255)
state = models.CharField(max_length=255)
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.slug} ({self.state})"

View file

@ -0,0 +1,578 @@
"""Data helpers for building/importing a user's offline export; deal with toolshed models and CSV row shapes only, and know nothing about the zip container format or request auth - see toolshed/api/offlinedata.py for that."""
def inventory_rows(user):
"""Generator that yields the given user's inventory items as flattened dicts, one per row."""
import mimetypes
from toolshed.models import InventoryItem
items = (InventoryItem.objects
.filter(owner=user)
.select_related('category', 'storage_location')
.prefetch_related('tags', 'itemproperty_set__property', 'files'))
for item in items:
file_paths = []
for f in item.files.all():
extension = mimetypes.guess_extension(f.mime_type) or ''
file_paths.append(f'files/{f.hash}{extension}')
yield {
'id': item.id,
'name': item.name or '',
'description': item.description or '',
'category': item.category.get_handle() if item.category else '',
'availability_policy': item.availability_policy,
'owned_quantity': item.owned_quantity,
'storage_location': str(item.storage_location) if item.storage_location else '',
'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
'properties': _encode_properties_cell(item.itemproperty_set.all()),
'files': ', '.join(file_paths),
'created_at': item.created_at.isoformat() if item.created_at else '',
}
def friend_rows(user):
"""Generator that yields the given user's friends (known identities) as flattened dicts, one per row."""
friends = user.friends.all()
for friend in friends:
yield {
'username': friend.username,
'domain': friend.domain,
'handle': f'{friend.username}@{friend.domain}',
'public_key': friend.public_key,
}
def location_path(location):
"""Recursively build the '/'-joined path of a StorageLocation, following its parent chain."""
if location.parent:
return location_path(location.parent) + '/' + location.name
return location.name
def location_rows(user):
"""Generator that yields the given user's storage locations as flattened dicts, one per row."""
from toolshed.models import StorageLocation
locations = StorageLocation.objects.filter(owner=user).select_related('category', 'parent')
for location in locations:
yield {
'id': location.id,
'name': location.name,
'description': location.description or '',
'category': str(location.category) if location.category else '',
'parent': str(location.parent) if location.parent else '',
'path': location_path(location),
}
def inventory_files(user):
"""Yields (arcname, data) for each unique file attached to the user's inventory items, deduplicated by hash. See docs/implementation.md#file-naming-convention-in-exports for the naming scheme."""
import mimetypes
from toolshed.models import InventoryItem
seen_hashes = set()
items = InventoryItem.objects.filter(owner=user).prefetch_related('files')
for item in items:
for file in item.files.all():
if file.hash in seen_hashes:
continue
seen_hashes.add(file.hash)
extension = mimetypes.guess_extension(file.mime_type) or ''
arcname = f'files/{file.hash}{extension}'
file.file.open('rb')
try:
data = file.file.read()
finally:
file.file.close()
yield arcname, data
def profile_data(user):
"""Return the given user's profile as a dict, matching profile.json in the export zip."""
import mimetypes
data = {
'username': user.username,
'domain': user.domain,
'email': user.email,
'first_name': user.first_name or '',
'last_name': user.last_name or '',
'profile_picture': None,
}
if user.profile_picture:
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
data['profile_picture'] = f'files/{user.profile_picture.hash}{extension}'
return data
def profile_picture_files(user):
"""Generator that yields (arcname, data) for the user's profile picture, if one is set. Kept separate from `inventory_files()` so the picture is included even for users with no inventory items or whose picture isn't attached to any item."""
import mimetypes
if not user.profile_picture:
return
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
arcname = f'files/{user.profile_picture.hash}{extension}'
user.profile_picture.file.open('rb')
try:
data = user.profile_picture.file.read()
finally:
user.profile_picture.file.close()
yield arcname, data
def settings_data(user):
"""Return the given user's account-level preferences as a {key: value} dict for settings.json. Only account preferences are exported; device-level preferences stay in the browser's local storage since they describe the device, not the account."""
return {pref.key: pref.value for pref in user.preferences.all()}
def import_profile(user, data, available_files):
"""Fault-tolerant import of profile.json, updating the user's editable profile fields. See docs/implementation.md#profile-import-semantics for which fields are applied and why."""
import json
try:
profile = json.loads(data.decode('utf-8'))
if not isinstance(profile, dict):
return False
if 'first_name' in profile:
user.first_name = profile.get('first_name') or ''
if 'last_name' in profile:
user.last_name = profile.get('last_name') or ''
if profile.get('email'):
user.email = profile['email']
picture_path = profile.get('profile_picture')
if picture_path and picture_path in available_files:
user.profile_picture = available_files[picture_path]
user.save()
return True
except Exception as error:
print(f'Skipping profile.json: {error}')
return False
def import_settings(user, data):
"""Fault-tolerant import of settings.json; each top-level key/value pair is upserted as an AccountPreference, and unreadable data or a non-object payload is skipped rather than aborting the whole import."""
import json
from authentication.models import AccountPreference
try:
settings = json.loads(data.decode('utf-8'))
if not isinstance(settings, dict):
return 0
imported = 0
for key, value in settings.items():
try:
AccountPreference.objects.update_or_create(user=user, key=key, defaults={'value': value})
imported += 1
except Exception as error:
print(f'Skipping setting "{key}": {error}')
return imported
except Exception as error:
print(f'Skipping settings.json: {error}')
return 0
def delete_user_data(user):
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact. Returns a summary dict describing what was removed. See docs/implementation.md#account-data-deletion for exactly what's removed and why the account itself survives."""
from django.db import transaction
from toolshed.models import InventoryItem, StorageLocation
summary = {'inventory_items': 0, 'locations': 0, 'settings': 0, 'friends': 0, 'files': 0}
with transaction.atomic():
candidate_file_ids = set(
InventoryItem.global_objects.filter(owner=user).values_list('files__id', flat=True))
candidate_file_ids.discard(None)
if user.profile_picture_id:
candidate_file_ids.add(user.profile_picture_id)
for item in InventoryItem.global_objects.filter(owner=user):
item.hard_delete()
summary['inventory_items'] += 1
summary['locations'], _ = StorageLocation.objects.filter(owner=user).delete()
summary['settings'], _ = user.preferences.all().delete()
summary['friends'] = user.public_identity.friends.count()
user.public_identity.friends.clear()
user.profile_picture = None
user.save(update_fields=['profile_picture'])
summary['files'] = _delete_orphaned_files(candidate_file_ids)
return summary
def delete_user_account(user):
"""Permanently delete the local user's account, after wiping all of its data via `delete_user_data()`. Returns a summary dict with `account` set to True. See docs/implementation.md#account-data-deletion for why the underlying KnownIdentity is kept."""
from django.db import transaction
with transaction.atomic():
summary = delete_user_data(user)
user.delete()
summary['account'] = True
return summary
def _delete_orphaned_files(file_ids):
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. Returns the number of files deleted. See docs/implementation.md#account-data-deletion for the orphan definition."""
from files.models import File
deleted = 0
for file_obj in File.objects.filter(id__in=file_ids):
if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists() \
or file_obj.staged_by_workflows.exists():
continue
file_obj.file.delete(save=False)
file_obj.delete()
deleted += 1
return deleted
def rows_to_csv(rows, fieldnames=None, encoding='utf-8'):
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
import csv
class _Echo:
"""A file-like object whose write() just returns what was passed, for streaming csv.writer output."""
def write(self, value):
return value
rows = iter(rows)
try:
first_row = next(rows)
except StopIteration:
return
if fieldnames is None:
fieldnames = list(first_row.keys())
writer = csv.DictWriter(_Echo(), fieldnames=fieldnames)
yield writer.writeheader().encode(encoding)
yield writer.writerow(first_row).encode(encoding)
for row in rows:
yield writer.writerow(row).encode(encoding)
def _read_csv_rows(data, encoding='utf-8'):
"""Decode CSV bytes and yield rows as dicts keyed by the header labels (not column offsets)."""
import csv
import io
reader = csv.DictReader(io.StringIO(data.decode(encoding)))
for row in reader:
yield row
def _field_is_optional(model, field_name):
"""Return True if a field may be omitted: it's a relation, has a default, or allows null/blank."""
try:
field = model._meta.get_field(field_name)
except Exception:
return True
if getattr(field, 'many_to_many', False) or getattr(field, 'one_to_many', False):
return True
return bool(getattr(field, 'null', False) or getattr(field, 'blank', False) or field.has_default())
def get_or_create_category(path):
"""Resolve or create a Category from a '/'-separated path such as 'Electronics/Cables'."""
from toolshed.models import Category
parent = None
category = None
for part in (p for p in path.split('/') if p):
category, _ = Category.objects.get_or_create(name=part, parent=parent, defaults={'origin': 'import'})
parent = category
return category
def import_locations(user, data):
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`. See docs/implementation.md#location-import-ordering-and-savepoints for row ordering and error-isolation rules."""
from django.db import transaction
from toolshed.models import StorageLocation
rows = list(_read_csv_rows(data))
rows.sort(key=lambda row: (row.get('path') or row.get('name') or '').count('/'))
resolved_by_path = {}
imported = 0
for row in rows:
try:
with transaction.atomic():
name = (row.get('name') or '').strip()
if not name and not _field_is_optional(StorageLocation, 'name'):
continue # required column missing, skip this row
path = (row.get('path') or name).strip()
parent = None
if '/' in path:
parent = resolved_by_path.get(path.rsplit('/', 1)[0])
category = None
category_path = row.get('category')
if category_path:
category = get_or_create_category(category_path)
defaults = {
'description': row.get('description', '') or '',
'category': category,
}
try:
location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
for field, value in defaults.items():
setattr(location, field, value)
location.save(update_fields=list(defaults.keys()))
except StorageLocation.DoesNotExist:
location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults)
resolved_by_path[path] = location
imported += 1
except Exception as error:
print(f'Skipping location row {row}: {error}')
return imported
def import_friends(user, data):
"""Fault-tolerant import of friends.csv, adding valid entries to the user's known friends."""
from django.db import transaction
from authentication.models import KnownIdentity
imported = 0
for row in _read_csv_rows(data):
try:
with transaction.atomic():
username = row.get('username')
domain = row.get('domain')
handle = row.get('handle')
if (not username or not domain) and handle and '@' in handle:
username, domain = handle.split('@', 1)
public_key = row.get('public_key')
if not username or not domain or not public_key:
continue # required fields missing, skip this row
identity, _ = KnownIdentity.objects.get_or_create(
username=username, domain=domain,
defaults={'public_key': public_key},
)
user.public_identity.friends.add(identity)
imported += 1
except Exception as error:
print(f'Skipping friend row {row}: {error}')
return imported
class _HandleNotFound(Exception):
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved, aborting the row rather than creating a new entity. See docs/implementation.md#handle-resolution-semantics."""
def _resolve_handle(value, entity_type, model):
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance; never creates one. See docs/implementation.md#handle-resolution-semantics for the rationale."""
origin, rest = value.split('#', 1)
if ':' in rest:
found_type, name = rest.split(':', 1)
if found_type != entity_type:
raise _HandleNotFound(
f"expected a {entity_type} handle but got '{value}' (type '{found_type}')")
else:
name = rest
try:
return model.objects.get(origin=origin, name=name)
except model.DoesNotExist:
raise _HandleNotFound(f"{entity_type} '{value}' does not exist, skipping item")
def _quote_value_if_needed(value):
"""Wrap `value` in double quotes (CSV-style, doubling embedded quotes) if it contains a comma or quote character. See docs/implementation.md#properties-csv-encoding."""
if any(ch in value for ch in ',"'):
return '"' + value.replace('"', '""') + '"'
return value
def _encode_properties_cell(item_properties):
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties' CSV cell. See docs/implementation.md#properties-csv-encoding and `_parse_properties_cell()` for the reader side."""
entries = [
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
for ip in item_properties
]
return ', '.join(entries)
def _split_quoted_comma_list(raw_value):
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style) so a quoted value's own commas aren't mistaken for separators. See docs/implementation.md#properties-csv-encoding for the quoting/whitespace rules this implements."""
entries = []
current = []
in_quotes = False
i = 0
length = len(raw_value)
while i < length:
char = raw_value[i]
if char == '"':
if in_quotes and i + 1 < length and raw_value[i + 1] == '"':
current.append('"')
i += 2
continue
in_quotes = not in_quotes
i += 1
continue
if char == ',' and not in_quotes:
entries.append(''.join(current))
current = []
i += 1
if i < length and raw_value[i] == ' ':
i += 1 # skip the single space of the ", " separator
continue
current.append(char)
i += 1
entries.append(''.join(current))
return [entry for entry in entries if entry]
def _parse_properties_cell(raw_value, resolve_property):
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples. See docs/implementation.md#properties-csv-encoding for how the cell is encoded by `_encode_properties_cell()`."""
raw_value = (raw_value or '').strip()
if not raw_value:
return []
properties = []
for prop_entry in _split_quoted_comma_list(raw_value):
if '=' not in prop_entry:
continue
prop_name, value = prop_entry.split('=', 1)
prop = resolve_property(prop_name.strip())
if prop:
properties.append((prop, value))
return properties
def import_inventory(user, data, available_files):
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. See docs/implementation.md#inventory-import-semantics for file/handle resolution and error-isolation rules."""
from django.db import transaction
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
def resolve_category_from_csv(value):
"""Resolve a category from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'category', Category)
# Fallback to old path-based lookup
return get_or_create_category(value)
def resolve_tag_from_csv(value):
"""Resolve a tag from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'tag', Tag)
# Fallback to old name-only lookup or create
return Tag.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
def resolve_property_from_csv(value):
"""Resolve a property from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'property', Property)
# Fallback to old name-only lookup or create
return Property.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
imported = 0
errors = []
for row in _read_csv_rows(data):
name = (row.get('name') or '').strip()
try:
with transaction.atomic():
file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()]
files = [available_files[p] for p in file_paths if p in available_files]
if not name and not files:
continue # InventoryItem requires a name or at least one file
category = resolve_category_from_csv((row.get('category') or '').strip())
tags = []
for tag_name in (row.get('tags') or '').split(','):
tag_name = tag_name.strip()
if tag_name:
tag = resolve_tag_from_csv(tag_name)
if tag:
tags.append(tag)
properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
storage_location = None
location_path_value = row.get('storage_location')
if location_path_value:
storage_location = StorageLocation.objects.filter(
owner=user, name=location_path_value.rsplit('/', 1)[-1]).first()
try:
owned_quantity = int(row.get('owned_quantity') or 1)
except (TypeError, ValueError):
owned_quantity = 1
item = InventoryItem.create_for_owner(
owner=user,
name=name or None,
description=row.get('description', '') or '',
category=category,
availability_policy=row.get('availability_policy') or 'private',
owned_quantity=owned_quantity,
storage_location=storage_location,
)
for tag in tags:
item.tags.add(tag, through_defaults={})
for prop, value in properties:
ItemProperty.objects.create(inventory_item=item, property=prop, value=value)
for file in files:
item.files.add(file)
imported += 1
except _HandleNotFound as error:
message = f"Skipping item '{name or 'unnamed'}': {error}"
print(message)
errors.append(message)
except Exception as error:
message = f'Skipping inventory row {row}: {error}'
print(message)
errors.append(message)
return imported, errors

View file

@ -1,9 +1,52 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
from authentication.serializers import OwnerSerializer
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \
GroupMembership
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
from files.models import File
from files.serializers import FileSerializer
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
def parse_handle(handle):
"""Parse a fully qualified handle like 'git:base#property:length' into (origin, entity_type, name)"""
if '#' not in handle:
# Fallback to old format (just name)
return None, None, handle
origin, rest = handle.split('#', 1)
if ':' not in rest:
return origin, None, rest
entity_type, name = rest.split(':', 1)
return origin, entity_type, name
def resolve_category_handle(handle):
"""Resolve a fully qualified handle to a Category object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'category':
return Category.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Category.objects.get(name=handle.split('/')[-1])
def resolve_property_handle(handle):
"""Resolve a fully qualified handle to a Property object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'property':
return Property.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Property.objects.get(name=handle)
def resolve_tag_handle(handle):
"""Resolve a fully qualified handle to a Tag object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'tag':
return Tag.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Tag.objects.get(name=handle)
class FriendSerializer(serializers.ModelSerializer):
@ -28,35 +71,148 @@ class FriendRequestSerializer(serializers.ModelSerializer):
return obj.befriender_username + '@' + obj.befriender_domain
class GroupMemberSerializer(serializers.ModelSerializer):
username = serializers.SerializerMethodField()
class Meta:
model = KnownIdentity
fields = ['id', 'username', 'public_key']
def get_username(self, obj):
return obj.username + '@' + obj.domain
class GroupSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
members = GroupMemberSerializer(many=True, read_only=True)
class Meta:
model = Group
fields = ['id', 'name', 'domain', 'handle', 'members']
read_only_fields = ['domain', 'handle', 'members']
def get_handle(self, obj):
return str(obj)
class GroupIdMapSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ['id', 'name', 'domain', 'handle']
def get_handle(self, obj):
return str(obj)
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
group = serializers.SerializerMethodField()
inviter = serializers.SerializerMethodField()
class Meta:
model = GroupInviteIncoming
fields = ['id', 'group', 'inviter', 'secret']
def get_group(self, obj):
return f"#{obj.group_name}@{obj.group_domain}"
def get_inviter(self, obj):
return obj.inviter_username + '@' + obj.inviter_domain
class GroupMembershipSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta:
model = GroupMembership
fields = ['id', 'group_name', 'group_domain', 'handle', 'created_at']
def get_handle(self, obj):
return f"#{obj.group_name}@{obj.group_domain}"
class PropertySerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
category = serializers.SerializerMethodField()
handle = serializers.SerializerMethodField()
def get_category(self, obj):
return resolve_category_handle(obj.category.get_handle()) if obj.category else None
def get_handle(self, obj):
return obj.get_handle()
class Meta:
model = Property
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix']
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', 'handle']
class CategorySerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['name']
fields = ['name', 'handle']
def get_handle(self, obj):
return obj.get_handle()
def to_representation(self, instance):
return str(instance)
return instance.name
def to_internal_value(self, data):
return Category.objects.get(name=data.split("/")[-1])
return resolve_category_handle(data.split("/")[-1])
class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to
the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly
visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not
internal_id."""
def use_pk_only_optimization(self):
# False: to_representation needs the owner-scoped `id`, not just the internal pk that the
# PKOnlyObject optimization would otherwise limit us to.
return False
def get_queryset(self):
queryset = super().get_queryset()
request = self.context.get('request')
if request is None or type(request.user) != KnownIdentity:
return queryset.none()
identity = request.user
owner_filter = Q(owner_group__in=identity.member_of_groups.all())
if identity.user.exists():
owner_filter |= Q(owner=identity.user.get())
return queryset.filter(owner_filter)
def to_internal_value(self, data):
queryset = self.get_queryset()
try:
if isinstance(data, bool):
raise TypeError
return queryset.get(id=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)
def to_representation(self, value):
return value.id
class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
category = CategorySerializer(required=False, allow_null=True)
owner_group = GroupOwnerSerializer(read_only=True)
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
path = serializers.SerializerMethodField()
class Meta:
model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner']
read_only_fields = ['path']
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'owner_group', 'parent']
read_only_fields = ['id', 'path']
@staticmethod
def get_path(obj):
@ -64,38 +220,55 @@ class StorageLocationSerializer(serializers.ModelSerializer):
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
return obj.name
def create(self, validated_data):
return StorageLocation.create_for_owner(**validated_data)
class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True)
handle = serializers.SerializerMethodField()
class Meta:
model = ItemProperty
fields = ['property', 'value']
fields = ['property', 'value', 'handle']
def get_handle(self, obj):
return obj.property.get_handle()
def to_representation(self, instance):
return {'value': instance.value, 'name': instance.property.name}
def to_internal_value(self, data):
prop = Property.objects.get(name=data['name'])
prop = resolve_property_handle(data.get('name') or data.get('handle'))
value = data['value']
return {'property': prop, 'value': value}
class InventoryItemSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name')
owner_group = GroupOwnerSerializer(read_only=True)
tags = serializers.SerializerMethodField()
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True)
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
class Meta:
model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
'tags', 'properties', 'files', 'storage_location']
fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy',
'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location']
read_only_fields = ['id']
def get_tags(self, obj):
return [tag.name for tag in obj.tags.all()]
def to_internal_value(self, data):
files = data.pop('files', [])
tags_input = data.pop('tags_input', data.pop('tags', []))
ret = super().to_internal_value(data)
ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
ret['files'] = files
return ret
@ -103,7 +276,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
tags = validated_data.pop('tags', [])
props = validated_data.pop('itemproperty_set', [])
files = validated_data.pop('files', [])
item = InventoryItem.objects.create(**validated_data)
item = InventoryItem.create_for_owner(**validated_data)
for tag in tags:
item.tags.add(tag, through_defaults={})
for prop in props:
@ -138,3 +311,17 @@ class InventoryItemSerializer(serializers.ModelSerializer):
ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value'])
item.save()
return item
class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
# Only the hash is needed to identify a staged file, unlike InventoryItemSerializer.files. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
staged_files = serializers.SerializerMethodField()
class Meta:
model = WorkflowInstance
fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
read_only_fields = ['owner', 'staged_files', 'created_at', 'updated_at']
def get_staged_files(self, obj):
return list(obj.staged_files.values_list('hash', flat=True))

View file

@ -1,4 +1,5 @@
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
import json
class CategoryTestMixin:
@ -34,10 +35,10 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
def prepare_inventory(self):
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
self.f['item1'] = InventoryItem.objects.create(
self.f['item1'] = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='test1', description='test', category=self.f['cat1'],
availability_policy='friends')
self.f['item2'] = InventoryItem.objects.create(
self.f['item2'] = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='test2', description='test2', category=self.f['cat1'],
availability_policy='friends')
self.f['item2'].tags.add(self.f['tag1'], through_defaults={})
@ -48,9 +49,39 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
class LocationTestMixin:
def prepare_locations(self):
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
class WorkflowTestMixin:
def prepare_workflows(self):
# `payload` is an opaque, frontend-serialized JSON string on the backend.
self.f['workflow1'] = WorkflowInstance.objects.create(
name='workflow1',
state='initial',
payload=json.dumps({}),
owner=self.f['local_user1']
)
self.f['workflow2'] = WorkflowInstance.objects.create(
name='workflow1',
state='upload',
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}),
owner=self.f['local_user1']
)
self.f['workflow3'] = WorkflowInstance.objects.create(
name='workflow1',
state='describe',
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']}),
owner=self.f['local_user1']
)
self.f['workflow_user2'] = WorkflowInstance.objects.create(
name='workflow2',
state='initial',
payload=json.dumps({}),
owner=self.f['local_user2']
)

View file

@ -1,7 +1,7 @@
from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import File
from toolshed.models import File, InventoryItem
from toolshed.tests import InventoryTestMixin
@ -156,3 +156,60 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
self.assertEqual(reply.json()[0]['files'][0]['mime_type'], 'text/plain')
self.assertEqual(reply.json()[0]['files'][1]['mime_type'], 'text/plain')
self.assertEqual(reply.json()[1]['files'][0]['mime_type'], 'text/plain')
class GroupOwnedFileApiTestCase(UserTestMixin, GroupTestMixin, FilesTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
self.prepare_files()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
self.f['group_item'] = InventoryItem.create_for_owner(
owner_group=self.f['group1'], owned_quantity=1, name='group-drill', availability_policy='private')
self.f['group_item'].files.add(self.f['test_file1'])
def test_get_group_item_files(self):
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
def test_other_member_can_post_file(self):
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['local_user2'],
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
self.assertEqual(response.status_code, 201)
self.assertEqual(self.f['group_item'].files.count(), 2)
def test_remote_member_without_local_account_can_post_file(self):
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
self.assertEqual(response.status_code, 201)
self.assertEqual(self.f['group_item'].files.count(), 2)
def test_remote_member_without_local_account_can_get_files(self):
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
self.assertEqual(response.status_code, 200)
def test_non_member_cannot_post_file(self):
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_non_member_cannot_get_files(self):
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
self.assertEqual(response.status_code, 404)
def test_other_member_can_delete_file(self):
response = client.delete(f"/api/item_files/{self.f['group_item'].id}/{self.f['test_file1'].id}/",
self.f['local_user2'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['group_item'].files.count(), 0)
def test_non_member_cannot_delete_file(self):
response = client.delete(f"/api/item_files/{self.f['group_item'].id}/{self.f['test_file1'].id}/",
self.f['ext_user1'])
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)

View file

@ -85,20 +85,8 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(self.f['local_user1'].friends.count(), 1)
# what ~should~ happen:
# 1. user x@A sends a friend request to user y@B
# 1.1. x@A's client sends a POST request to A/api/friendrequests/ with body {from: x@A, to: y@B}
# 1.2. A's backend creates a FriendRequestOutgoing object, containing x@A's identity and y@B's name
# 1.3. x@A's client sends a POST request to B/api/friendrequests/ with body
# {from: x@A, to: y@B, public_key: x@A's public key}
# 1.4. B's backend creates a FriendRequestIncoming object, containing y@B's and x@A's identities
# 2. user y@B accepts the friend request
# 2.1. y@B's client sends a POST request to A/api/friendsrequests/ with body
# {from: x@A, to: y@B, public_key: y@B's public key}
# 2.2. A's backend matches the data to the FriendRequestOutgoing object, deletes both and creates a Friend object,
# containing x@A's and y@B's identities
# 2.3. y@B's client sends a POST request to B/api/friends/ containing the id of the FriendRequestIncoming object
# 2.4. B's backend creates a Friend object, using the identities from the FriendRequestIncoming object
# Friend request/accept protocol walkthrough. See
# docs/implementation.md#friend-request-and-accept-protocol-flow.
class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase):

View file

@ -0,0 +1,304 @@
from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
DummyExternalUser
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity
client = SignatureAuthClient()
class GroupModelTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_group_str(self):
self.assertEqual(str(self.f['group1']), '#group1@' + self.f['example_com'].name)
def test_is_member(self):
self.assertTrue(self.f['group1'].is_member(self.f['local_user1'].public_identity))
self.assertFalse(self.f['group1'].is_member(self.f['local_user2'].public_identity))
class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
def test_create_group(self):
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
self.assertEqual(reply.status_code, 201)
group = Group.objects.get(name='workshop')
self.assertEqual(group.domain, self.f['local_user1'].domain)
self.assertTrue(group.is_member(self.f['local_user1'].public_identity))
def test_create_group_duplicate_name(self):
client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
self.assertEqual(reply.status_code, 409)
def test_create_group_missing_name(self):
reply = client.post('/api/groups/', self.f['local_user1'], {})
self.assertEqual(reply.status_code, 400)
def test_list_groups(self):
self.prepare_groups()
reply = client.get('/api/groups/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
reply2 = client.get('/api/groups/', self.f['local_user2'])
self.assertEqual(reply2.status_code, 200)
self.assertEqual(len(reply2.json()), 0)
def test_group_detail_member(self):
self.prepare_groups()
group = self.f['group1']
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['handle'], str(group))
self.assertEqual(len(reply.json()['members']), 1)
def test_group_detail_non_member(self):
self.prepare_groups()
group = self.f['group1']
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_group_detail_no_such_group(self):
reply = client.get('/api/groups/nonexistent@example.com/', self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
def test_remove_member(self):
self.prepare_groups()
group = self.f['group1']
group.members.add(self.f['local_user2'].public_identity)
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
group.name, group.domain, self.f['local_user2'].public_identity.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(group.members.count(), 1)
def test_remove_last_member_blocked(self):
self.prepare_groups()
group = self.f['group1']
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
group.name, group.domain, self.f['local_user1'].public_identity.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 400)
self.assertEqual(group.members.count(), 1)
def test_remove_member_non_member_denied(self):
self.prepare_groups()
group = self.f['group1']
group.members.add(self.f['local_user2'].public_identity)
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
group.name, group.domain, self.f['local_user1'].public_identity.id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(group.members.count(), 2)
class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_invite_local_member_full_flow(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
self.assertEqual(reply1.status_code, 201)
secret = reply1.json()['secret']
self.assertEqual(GroupInvite.objects.count(), 1)
reply2 = client.post('/api/groupinvites/', inviter, {
'group': str(group), 'inviter': str(inviter), 'inviter_key': inviter.public_key(),
'invitee': str(invitee), 'secret': secret
})
self.assertEqual(reply2.status_code, 201)
incoming = GroupInviteIncoming.objects.get()
self.assertEqual(incoming.invitee_user, invitee)
reply3 = client.get('/api/groupinvites/', invitee)
self.assertEqual(reply3.status_code, 200)
self.assertEqual(len(reply3.json()), 1)
self.assertEqual(reply3.json()[0]['group'], str(group))
self.assertEqual(reply3.json()[0]['inviter'], str(inviter))
reply4 = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply4.status_code, 201)
self.assertTrue(group.is_member(invitee.public_identity))
self.assertEqual(GroupInvite.objects.count(), 0)
reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
self.assertEqual(reply5.status_code, 201)
self.assertEqual(reply5.json()['handle'], str(group))
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
membership = GroupMembership.objects.get(user=invitee)
self.assertEqual(membership.group_name, group.name)
self.assertEqual(membership.group_domain, group.domain)
def test_invite_non_member_denied(self):
group = self.f['group1']
reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user2'],
{'invitee': str(self.f['ext_user1'])})
self.assertEqual(reply.status_code, 404)
self.assertEqual(GroupInvite.objects.count(), 0)
def test_invite_already_member(self):
group = self.f['group1']
group.members.add(self.f['local_user2'].public_identity)
reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user1'],
{'invitee': str(self.f['local_user2'])})
self.assertEqual(reply.status_code, 208)
def test_decline_invite(self):
invite = GroupInviteIncoming.objects.create(
group_name=self.f['group1'].name, group_domain=self.f['group1'].domain,
inviter_username=self.f['local_user1'].username, inviter_domain=self.f['local_user1'].domain,
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.delete('/api/groupinvites/{}/'.format(invite.id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
def test_accept_wrong_secret(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter, {'invitee': str(invitee)})
reply = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(),
'secret': 'not-the-right-secret'
})
self.assertEqual(reply.status_code, 404)
self.assertFalse(group.is_member(invitee.public_identity))
def test_accept_remote_member(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = DummyExternalUser('newmember', 'remote.example', known=False)
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
secret = reply1.json()['secret']
reply = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply.status_code, 201)
identity = KnownIdentity.objects.get(username='newmember', domain='remote.example')
self.assertTrue(group.is_member(identity))
# This lands on the group's own home backend, not the invitee's -- the invitee here isn't
# even a local ToolshedUser on this backend, so there's nothing to point at locally.
self.assertEqual(GroupMembership.objects.count(), 0)
def test_accept_bad_signature(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
secret = reply1.json()['secret']
bad_signature_client = SignatureAuthClient(bad_signature=True)
reply = bad_signature_client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply.status_code, 401)
self.assertFalse(group.is_member(invitee.public_identity))
class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
"""Covers the pointer index kept on a member's own home backend (GroupMembership), which is
what lets that backend remember foreign group membership, analogous to how a friendship ends
up recorded on both sides rather than only on the group's authoritative backend."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_record_membership_for_remote_group(self):
# local_user2's own home backend has no Group row at all for this group -- it's hosted
# entirely on another domain -- yet it should still remember the membership once the
# invitee's client confirms the accept against the group's home backend succeeded.
invitee = self.f['local_user2']
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=invitee, secret='some-secret')
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
self.assertEqual(reply.status_code, 201)
self.assertEqual(reply.json()['handle'], '#remoteworkshop@other.example')
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
membership = GroupMembership.objects.get(user=invitee)
self.assertEqual(membership.group_name, 'remoteworkshop')
self.assertEqual(membership.group_domain, 'other.example')
def test_record_membership_wrong_user_denied(self):
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(GroupMembership.objects.count(), 0)
self.assertEqual(GroupInviteIncoming.objects.count(), 1)
def test_decline_does_not_record_membership(self):
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.delete('/api/groupinvites/{}/'.format(incoming.id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(GroupMembership.objects.count(), 0)
def test_record_membership_already_a_member_is_idempotent(self):
# Re-invited (or re-accepting) into a group we already have a pointer for shouldn't blow
# up on the unique_together constraint, and shouldn't duplicate the pointer either.
invitee = self.f['local_user2']
GroupMembership.objects.create(
user=invitee, group_name='remoteworkshop', group_domain='other.example')
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=invitee, secret='some-secret')
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
self.assertEqual(reply.status_code, 201)
self.assertEqual(GroupMembership.objects.filter(user=invitee).count(), 1)
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
def test_list_memberships(self):
GroupMembership.objects.create(
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
reply = client.get('/api/groupmemberships/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['handle'], '#remoteworkshop@other.example')
reply2 = client.get('/api/groupmemberships/', self.f['local_user2'])
self.assertEqual(reply2.status_code, 200)
self.assertEqual(len(reply2.json()), 0)
def test_list_memberships_unauthorized(self):
GroupMembership.objects.create(
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
reply = client.get('/api/groupmemberships/', self.f['ext_user1'])
# authenticate() returns bare None (not raise) for a caller with no local ToolshedUser, so
# DRF falls through to IsAuthenticated denying an anonymous request -- 403, not 401 (same
# as any other SignatureAuthenticationLocal-only endpoint, see e.g. dropFriend).
self.assertEqual(reply.status_code, 403)

View file

@ -0,0 +1,48 @@
from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
client = SignatureAuthClient()
class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
def test_idmap_includes_self(self):
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities])
def test_idmap_includes_friends(self):
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities])
def test_idmap_excludes_non_friends(self):
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities])
def test_idmap_includes_member_groups(self):
self.prepare_groups()
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
groups = reply.json()['groups']
self.assertIn(str(self.f['group1']), [g['handle'] for g in groups])
def test_idmap_excludes_non_member_groups(self):
self.prepare_groups()
reply = client.get('/api/idmap/', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
groups = reply.json()['groups']
self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups])
def test_idmap_unauthenticated(self):
reply = Client().get('/api/idmap/')
self.assertEqual(reply.status_code, 403)

View file

@ -1,7 +1,8 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from authentication.models import Group
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category
from toolshed.tests import InventoryTestMixin
from toolshed.tests import InventoryTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin, LocationTestMixin
client = SignatureAuthClient()
@ -213,6 +214,47 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_get_shared_item_as_friend(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['name'], 'test1')
self.assertEqual(reply.json()['owner'], 'testuser1@example.com')
def test_get_shared_item_as_owner(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['name'], 'test1')
def test_get_shared_item_not_friend(self):
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
self.f['ext_user1'])
self.assertEqual(reply.status_code, 403)
def test_get_shared_item_private(self):
private_item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 403)
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_get_shared_item_unknown_handle(self):
reply = client.get('/api/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_get_shared_item_unknown_id(self):
reply = client.get('/api/inventory_items/testuser1@example.com/99999/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_get_shared_item_bad_handle(self):
reply = client.get('/api/inventory_items/testuser1/' + str(self.f['item1'].id) + '/', self.f['local_user2'])
self.assertEqual(reply.status_code, 400)
class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self):
@ -292,4 +334,248 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
'files': [{'data': self.f['encoded_content3']}]
})
self.assertEqual(reply.status_code, 400)
self.assertEqual(reply.status_code, 400)
class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTestMixin, TagTestMixin,
PropertyTestMixin, FilesTestMixin, LocationTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
self.prepare_files()
self.prepare_locations()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
def create_group_item(self, name='drill'):
return client.post('/api/inventory_items/', self.f['local_user1'], {
'name': name, 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': str(self.f['group1'])[1:],
})
def test_create_group_owned_item(self):
reply = self.create_group_item()
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertIsNone(item.owner)
self.assertEqual(item.owner_group, self.f['group1'])
self.assertEqual(reply.json()['owner_group'], str(self.f['group1']))
self.assertIsNone(reply.json()['owner'])
def test_create_group_owned_item_non_member_denied(self):
reply = client.post('/api/inventory_items/', self.f['ext_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 403)
self.assertEqual(InventoryItem.objects.count(), 0)
def test_other_member_can_edit(self):
item_id = self.create_group_item().json()['id']
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
'name': 'drill-renamed'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed')
def test_other_member_can_delete(self):
item_id = self.create_group_item().json()['id']
reply = client.delete('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
def test_remote_member_without_local_account_can_edit(self):
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
# items - not unauthorized just because .user.exists() is False.
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
item_id = self.create_group_item().json()['id']
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'], {
'name': 'drill-renamed-by-remote-member'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed-by-remote-member')
def test_non_member_cannot_see_or_edit(self):
item_id = self.create_group_item().json()['id']
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_group_items_excluded_from_personal_list(self):
self.create_group_item()
reply = client.get('/api/inventory_items/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_group_items_listed_by_group_query_param(self):
self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['name'], 'drill')
def test_group_items_not_listed_for_non_member_query_param(self):
self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_create_group_owned_item_with_full_fields(self):
# Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach
# to a group-owned item the same way as a personal one.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'rent',
'category': 'cat2',
'name': 'drill',
'description': 'test',
'owned_quantity': 3,
'tags': ['tag1', 'tag2'],
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertIsNone(item.owner)
self.assertEqual(item.owner_group, self.f['group1'])
self.assertEqual(item.availability_policy, 'rent')
self.assertEqual(item.category, Category.objects.get(name='cat2'))
self.assertEqual(item.owned_quantity, 3)
self.assertEqual([t for t in item.tags.all()], [self.f['tag1'], self.f['tag2']])
self.assertEqual([p for p in item.properties.all()], [self.f['prop1'], self.f['prop2']])
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value1', 'value2'])
def test_create_group_owned_item_empty_fails(self):
# Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 400)
self.assertEqual(InventoryItem.objects.count(), 0)
def test_create_group_owned_item_nonexistent_group(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': 'nonexistent@example.com',
})
self.assertEqual(reply.status_code, 404)
self.assertEqual(InventoryItem.objects.count(), 0)
def test_group_items_listed_for_nonexistent_group_query_param(self):
reply = client.get('/api/inventory_items/?group=nonexistent@example.com', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_put_group_item(self):
# Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than
# the creator, to exercise the _is_authorized branch in perform_update for PUT too.
item_id = self.create_group_item().json()['id']
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
'availability_policy': 'sell',
'name': 'drill-4000',
'description': 'new description',
'owned_quantity': 100,
'tags': ['tag1', 'tag3'],
'properties': [{'name': 'prop1', 'value': 'value5'}],
})
self.assertEqual(reply.status_code, 200)
item = InventoryItem.objects.get(id=item_id)
self.assertEqual(item.owner_group, self.f['group1'])
self.assertEqual(item.availability_policy, 'sell')
self.assertEqual(item.name, 'drill-4000')
self.assertEqual(item.description, 'new description')
self.assertEqual(item.owned_quantity, 100)
self.assertEqual([t for t in item.tags.all()], [self.f['tag1'], self.f['tag3']])
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value5'])
def test_patch_group_item_clears_fields(self):
# Parity with InventoryApiTestCase.test_patch_item2 - clearing category/tags/properties.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'category': 'cat1', 'tags': ['tag1'],
'owner_group': str(self.f['group1'])[1:],
})
item_id = reply.json()['id']
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
'category': None, 'tags': [], 'properties': []
})
self.assertEqual(reply.status_code, 200)
item = InventoryItem.objects.get(id=item_id)
self.assertEqual(item.category, None)
self.assertEqual([t for t in item.tags.all()], [])
def test_group_item_storage_location(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'storage_location': self.f['loc1'].id, 'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertEqual(item.storage_location, self.f['loc1'])
def test_post_group_item_with_file_id(self):
# Parity with TestInventoryItemWithFileApiTestCase.test_post_item_with_file_id.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'files': [self.f['test_file1'].id], 'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertEqual([f for f in item.files.all()], [self.f['test_file1']])
def test_post_group_item_with_encoded_file(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'files': [{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'}],
'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
def test_group_items_excluded_from_search(self):
# Group-owned items are reachable only via the group's own detail page for MVP (see
# docs/design-in-progress/groups-mvp.md) - search must not surface them either.
self.create_group_item(name='searchable-drill')
InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal')
reply = client.get('/api/search/?query=searchable', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
names = [item['name'] for item in reply.json()]
self.assertEqual(names, ['searchable-personal'])
class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase):
"""InventoryItem.id is sequential, gapless, and never reused within each owner/owner_group's
own items, allocated independently per scope (see OwnerItemSequence)."""
def setUp(self):
super().setUp()
self.prepare_users()
def test_ids_are_sequential_and_independent_per_owner(self):
user1_items = [InventoryItem.create_for_owner(owner=self.f['local_user1'], name=f'u1-{i}')
for i in range(3)]
user2_items = [InventoryItem.create_for_owner(owner=self.f['local_user2'], name=f'u2-{i}')
for i in range(2)]
self.assertEqual([item.id for item in user1_items], [1, 2, 3])
self.assertEqual([item.id for item in user2_items], [1, 2])
def test_deleted_item_id_is_never_reused(self):
item1 = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='first')
item2 = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='second')
self.assertEqual((item1.id, item2.id), (1, 2))
item2.delete() # soft delete - item2's row (and its id) stays in the table
item3 = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='third')
self.assertEqual(item3.id, 3)
self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], id=2).exists())
self.assertTrue(InventoryItem.global_objects.filter(owner=self.f['local_user1'], id=2).exists())
def test_group_scope_has_independent_sequence(self):
group = Group.objects.create(name='alloc-test-group', domain=self.f['example_com'].name)
personal_item = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='personal')
group_item = InventoryItem.create_for_owner(owner_group=group, name='group-owned')
self.assertEqual(personal_item.id, 1)
self.assertEqual(group_item.id, 1)

View file

@ -1,6 +1,6 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category
from toolshed.models import InventoryItem, Category, StorageLocation
from toolshed.tests import InventoryTestMixin, LocationTestMixin
client = SignatureAuthClient()
@ -69,3 +69,170 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.json()[3]['description'], None)
self.assertEqual(reply.json()[3]['category'], 'cat1')
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
def test_post_new_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'description': 'a new location',
})
self.assertEqual(reply.status_code, 201)
self.assertEqual(StorageLocation.objects.count(), 5)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.description, 'a new location')
self.assertEqual(location.owner, self.f['local_user1'])
self.assertEqual(location.parent, None)
self.assertEqual(reply.json()['path'], 'loc5')
def test_post_new_nested_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'parent': self.f['loc3'].id,
})
self.assertEqual(reply.status_code, 201)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.parent, self.f['loc3'])
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
def test_patch_location(self):
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
'name': 'loc2-renamed',
'parent': self.f['loc1'].id,
})
self.assertEqual(reply.status_code, 200)
location = StorageLocation.objects.get(id=self.f['loc2'].id)
self.assertEqual(location.name, 'loc2-renamed')
self.assertEqual(location.parent, self.f['loc1'])
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
def test_delete_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(StorageLocation.objects.count(), 3)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
def test_delete_location_with_items_sets_null(self):
item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
item.refresh_from_db()
self.assertIsNone(item.storage_location)
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
def test_locations_are_owner_scoped(self):
reply = client.get('/api/storage_locations/', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_cannot_delete_other_users_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)
class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
def create_group_location(self, name='shelf'):
return client.post('/api/storage_locations/', self.f['local_user1'], {
'name': name, 'owner_group': str(self.f['group1'])[1:],
})
def test_create_group_owned_location(self):
reply = self.create_group_location()
self.assertEqual(reply.status_code, 201)
location = StorageLocation.objects.get(name='shelf')
self.assertIsNone(location.owner)
self.assertEqual(location.owner_group, self.f['group1'])
self.assertEqual(reply.json()['owner_group'], str(self.f['group1']))
self.assertIsNone(reply.json()['owner'])
def test_create_group_owned_location_non_member_denied(self):
reply = client.post('/api/storage_locations/', self.f['ext_user1'], {
'name': 'shelf', 'owner_group': str(self.f['group1'])[1:],
})
self.assertEqual(reply.status_code, 403)
self.assertEqual(StorageLocation.objects.count(), 0)
def test_other_member_can_edit(self):
location_id = self.create_group_location().json()['id']
reply = client.patch('/api/storage_locations/{}/'.format(location_id), self.f['local_user2'], {
'name': 'shelf-renamed'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed')
def test_other_member_can_delete(self):
location_id = self.create_group_location().json()['id']
reply = client.delete('/api/storage_locations/{}/'.format(location_id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(StorageLocation.objects.filter(id=location_id).count(), 0)
def test_remote_member_without_local_account_can_edit(self):
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
# locations - not unauthorized just because .user.exists() is False.
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
location_id = self.create_group_location().json()['id']
reply = client.get('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
reply = client.patch('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'], {
'name': 'shelf-renamed-by-remote-member'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed-by-remote-member')
def test_non_member_cannot_see_or_edit(self):
location_id = self.create_group_location().json()['id']
reply = client.get('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_group_locations_excluded_from_personal_list(self):
self.create_group_location()
reply = client.get('/api/storage_locations/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_group_locations_listed_by_group_query_param(self):
self.create_group_location()
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['name'], 'shelf')
def test_group_locations_not_listed_for_non_member_query_param(self):
self.create_group_location()
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_group_location_can_be_parent_for_group_member(self):
parent_id = self.create_group_location('shelf').json()['id']
reply = client.post('/api/storage_locations/', self.f['local_user2'], {
'name': 'bin', 'owner_group': str(self.f['group1'])[1:], 'parent': parent_id,
})
self.assertEqual(reply.status_code, 201)
self.assertEqual(reply.json()['path'], 'shelf/bin')
def test_own_personal_location_can_be_parent_of_group_location(self):
# OwnerScopedPrimaryKeyRelatedField (see serializers.py) scopes `parent` to every
# location the requester can act on - own personal ones plus any group's - with no
# further check that it matches the new location's own owner_group. Same behavior
# InventoryItem.storage_location already had before group ownership existed here; not
# something this feature narrows.
personal = StorageLocation.create_for_owner(name='mine', owner=self.f['local_user1'])
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'bin', 'owner_group': str(self.f['group1'])[1:], 'parent': personal.id,
})
self.assertEqual(reply.status_code, 201)
def test_group_location_not_valid_parent_for_non_member(self):
parent_id = self.create_group_location('shelf').json()['id']
reply = client.post('/api/storage_locations/', self.f['ext_user1'], {
'name': 'bin', 'parent': parent_id,
})
self.assertEqual(reply.status_code, 400)

View file

@ -0,0 +1,258 @@
from django.core.files.base import ContentFile
from django.test import Client
from authentication.models import AccountPreference, ToolshedUser
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
from files.models import File
from toolshed.models import InventoryItem, ItemProperty, StorageLocation
from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv
from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin
anonymous_client = Client()
client = SignatureAuthClient()
class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
"""Shared fixture setup for the delete-data and delete-account test cases."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_locations()
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
self.f['shared_file'] = File.objects.create(
file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared')
self.f['orphan_file'] = File.objects.create(
file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan')
self.f['item1'] = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1'])
self.f['item1'].files.add(self.f['orphan_file'])
self.f['item_other_user'] = InventoryItem.create_for_owner(
owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1'])
self.f['item_other_user'].files.add(self.f['shared_file'])
self.f['item1'].files.add(self.f['shared_file'])
AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark')
self.f['local_user1'].profile_picture = self.f['orphan_file']
self.f['local_user1'].save()
class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
def test_delete_data_anonymous(self):
response = anonymous_client.delete('/api/account_data/')
self.assertEqual(response.status_code, 403)
def test_delete_data_removes_all_owned_data_but_keeps_account(self):
response = client.delete('/api/account_data/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
summary = response.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['locations'], 4)
self.assertEqual(summary['settings'], 1)
self.assertEqual(summary['friends'], 1)
# the account itself survives - this wipes data, it doesn't close the account
self.f['local_user1'].refresh_from_db()
self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists())
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists())
self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists())
self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists())
self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0)
# orphaned file (only referenced by the deleted user/items) is gone
self.assertFalse(File.objects.filter(hash='orphan').exists())
# file still referenced by the other user's item survives
self.assertTrue(File.objects.filter(hash='shared').exists())
# the other user's data and identity/friend relation to the deleted identity are untouched
self.f['local_user2'].refresh_from_db()
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
def test_delete_account_anonymous(self):
response = anonymous_client.delete('/api/account/')
self.assertEqual(response.status_code, 403)
def test_delete_account_removes_data_and_closes_account(self):
user1_id = self.f['local_user1'].id
identity_id = self.f['local_user1'].public_identity_id
response = client.delete('/api/account/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
summary = response.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['locations'], 4)
self.assertEqual(summary['settings'], 1)
self.assertEqual(summary['friends'], 1)
self.assertTrue(summary['account'])
# the account itself is gone
self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists())
self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists())
self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists())
self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists())
# the underlying identity is kept, so remote friends/history referencing it stay intact
from authentication.models import KnownIdentity
self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists())
# orphaned file (only referenced by the deleted user/items) is gone
self.assertFalse(File.objects.filter(hash='orphan').exists())
# file still referenced by the other user's item survives
self.assertTrue(File.objects.filter(hash='shared').exists())
# the other user's data and identity/friend relation to the deleted identity are untouched
self.f['local_user2'].refresh_from_db()
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0)
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""Properties must round-trip through export/import even when their value contains a comma or '=' sign, which a naive "handle=value, handle2=value2" encoding would otherwise misinterpret as a separator."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
def test_property_values_with_comma_and_equals_round_trip(self):
item = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='widget')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget')
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
def test_legacy_comma_equals_format_is_still_importable(self):
handle1 = self.f['prop1'].get_handle()
handle2 = self.f['prop2'].get_handle()
csv_data = (
'name,properties\r\n'
f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n'
).encode('utf-8')
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget')
values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'})
def test_item_without_properties_imports_cleanly(self):
item = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='bare item')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item')
self.assertEqual(list(new_item.itemproperty_set.all()), [])
def test_category_and_tags_round_trip(self):
item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1'])
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item')
self.assertEqual(new_item.category, self.f['cat1'])
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
def test_unknown_property_handle_skips_item_with_error(self):
csv_data = (
'name,properties\r\n'
'ghost widget,test#property:doesnotexist=x\r\n'
).encode('utf-8')
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
self.assertEqual(imported, 0)
self.assertEqual(len(errors), 1)
self.assertIn('doesnotexist', errors[0])
self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists())
def test_property_value_with_quote_character_round_trips(self):
item = InventoryItem.create_for_owner(owner=self.f['local_user1'], name='quoted widget')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget')
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '12" screen'})
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by clients), rather than calling the internal helper functions directly - this is what a real export/import round trip between two accounts looks like."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
def test_export_then_import_preserves_category_tags_and_properties(self):
import base64
item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], name='drill', description='cordless drill',
category=self.f['cat1'], availability_policy='friends', owned_quantity=2)
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
export_reply = client.get('/api/export/', self.f['local_user1'])
self.assertEqual(export_reply.status_code, 200)
zip_bytes = export_reply.content
import_reply = client.post('/api/import/', self.f['local_user2'],
{'zip': base64.b64encode(zip_bytes).decode('ascii')})
self.assertEqual(import_reply.status_code, 200)
summary = import_reply.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['errors'], [])
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
self.assertEqual(new_item.category, self.f['cat1'])
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})

View file

@ -0,0 +1,48 @@
import json
from django.test import Client
from django.urls import reverse
from rest_framework import status
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
from toolshed.tests import WorkflowTestMixin
from toolshed.models import WorkflowInstance
anonymous_client = Client()
client = SignatureAuthClient()
class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTestCase):
"""Comprehensive test cases for the Workflow API"""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_workflows()
def test_get_workflow_instances(self):
reply = client.get('/api/workflows/', self.f['local_user1'])
self.assertEqual(reply.status_code, status.HTTP_200_OK)
self.assertEqual(len(reply.data), 3)
self.assertEqual(reply.data[0]['name'], 'workflow1')
self.assertEqual(reply.data[1]['name'], 'workflow1')
self.assertEqual(reply.data[2]['name'], 'workflow1')
self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(reply.data[1]['state'], 'upload')
self.assertEqual(reply.data[2]['state'], 'describe')
self.assertEqual(json.loads(reply.data[0]['payload']), {})
self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']})
def test_get_workflow_instances_user2(self):
reply = client.get('/api/workflows/', self.f['local_user2'])
self.assertEqual(reply.status_code, status.HTTP_200_OK)
self.assertEqual(len(reply.data), 1)
self.assertEqual(reply.data[0]['name'], 'workflow2')
self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(json.loads(reply.data[0]['payload']), {})

83
cli-client/toolshed-client.py Executable file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
import argparse
import os
import requests
from nacl.signing import SigningKey
from json import dumps
class ToolshedApi:
user = None
host = None
signing_key = None
def __init__(self, user, host, key):
if host is None:
raise ValueError("TOOLSHED_HOST environment variable not set")
if user is None:
raise ValueError("TOOLSHED_USER environment variable not set")
if key is None:
raise ValueError("TOOLSHED_KEY environment variable not set")
if len(key) != 64:
raise ValueError("TOOLSHED_KEY must be 64 hex characters")
signing_key = SigningKey(bytes.fromhex(key))
self.user = user
self.host = host
self.signing_key = signing_key
def get(self, target):
url = "http://" + self.host + target
signed = self.signing_key.sign(url.encode('utf-8'))
signature = signed.signature.hex()
response = requests.get(url, headers={"Authorization": "Signature " + self.user + ":" + signature})
return response.json()
def post(self, target, data):
url = "http://" + self.host + target
json = dumps(data)
signed = self.signing_key.sign(url.encode('utf-8') + json.encode('utf-8'))
signature = signed.signature.hex()
response = requests.post(url, headers={"Authorization": "Signature " + self.user + ":" + signature}, json=data)
return response.json()
def main():
host = os.environ.get('TOOLSHED_HOST')
user = os.environ.get('TOOLSHED_USER')
key = os.environ.get('TOOLSHED_KEY')
parser = argparse.ArgumentParser(description='Toolshed API client')
parser.add_argument('--host', help='Toolshed host')
parser.add_argument('--user', help='Toolshed user')
parser.add_argument('--key', help='Toolshed key')
parser.add_argument('cmd', help='Command')
args = parser.parse_args()
if args.host is not None:
host = args.host
if args.user is not None:
user = args.user
if args.key is not None:
key = args.key
api = ToolshedApi(user, host, key)
if args.cmd == 'getinventory':
inv = api.get("/api/inventory_items/")
print(inv)
elif args.cmd == 'additem':
inv = api.post("/api/inventory_items/", {"name": "test"})
print(inv)
else:
print("Unknown command: " + args.cmd)
if __name__ == '__main__':
main()

View file

@ -7,10 +7,11 @@ ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /code
RUN mkdir /git
# Install dependencies
COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt
RUN touch /mnt/db.sqlite3 && touch /mnt/testdata.py && mkdir /mnt/userfiles
# Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"]
CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure"]

View file

@ -3,11 +3,13 @@ FROM node:14
# Set work directory
WORKDIR /app
RUN mkdir /git
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package.json ./
COPY extras/ ./extras/
RUN npm install
CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -1,14 +1 @@
FROM nginx:bookworm
# snakeoil for localhost
RUN apt-get update && \
apt-get install -y openssl && \
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \
openssl rsa -passin pass:x -in server.pass.key -out server.key && \
rm server.pass.key && \
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\
mv server.crt /etc/nginx/nginx.crt && \
mv server.key /etc/nginx/nginx.key \

View file

@ -3,6 +3,9 @@ import socketserver
import urllib.parse
import dnslib
import base64
import socket
UPSTREAM_DNS = ("8.8.8.8", 53)
try:
@ -11,6 +14,13 @@ try:
if record["name"] == qname and record["type"] == qtype and "value" in record:
return record["value"]
def resolve_recursive(raw_query):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(5)
sock.sendto(raw_query, UPSTREAM_DNS)
data, _ = sock.recvfrom(4096)
return data
class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
@ -40,15 +50,16 @@ try:
print("SRV record")
reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"])
response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply))
pack = response.pack()
else:
response.header.rcode = dnslib.RCODE.NXDOMAIN
print(f"Recursively resolving {dns.q.qname}")
pack = resolve_recursive(raw)
print(response)
self.send_response(200)
self.send_header("Content-type", "application/dns-message")
self.end_headers()
pack = response.pack()
self.wfile.write(pack)
return
except Exception as e:

View file

@ -0,0 +1,110 @@
version: '3.8'
name: dev
services:
backend-a:
build:
context: ../../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
environment:
TOOLSHED_DB_PATH: /mnt/db.sqlite3
TOOLSHED_USERFILES_PATH: /mnt/userfiles
TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes:
- ../../backend:/code
- ../../.git:/git:ro
- ./instance_a/a.env:/code/.env
- ./instance_a/testdata.py:/mnt/testdata.py
- ./instance_a/a.sqlite3:/mnt/db.sqlite3
- ./instance_a/userfiles:/mnt/userfiles
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b:
build:
context: ../../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
environment:
TOOLSHED_DB_PATH: /mnt/db.sqlite3
TOOLSHED_USERFILES_PATH: /mnt/userfiles
TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes:
- ../../backend:/code
- ../../.git:/git:ro
- ./instance_b/b.env:/code/.env
- ./instance_b/testdata.py:/mnt/testdata.py
- ./instance_b/b.sqlite3:/mnt/db.sqlite3
- ./instance_b/userfiles:/mnt/userfiles
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend:
build:
context: ../../frontend/
dockerfile: ../deploy/dev/Dockerfile.frontend
volumes:
- ../../frontend:/app
- ../../.git:/git:ro
- /app/node_modules
expose:
- 5173
command: bash -c "npm install && npm run dev -- --host"
wiki:
build:
context: ../../
dockerfile: deploy/dev/Dockerfile.wiki
volumes:
- ../../mkdocs.yml:/wiki/mkdocs.yml
- ../../docs:/wiki/docs
expose:
- 8001
command: mkdocs serve --dev-addr=0.0.0.0:8001
proxy-a:
build:
context: ./
dockerfile: Dockerfile.proxy
volumes:
- ./instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
- ./instance_a/dns.json:/var/www/dns.json:ro
- ./instance_a/domains.json:/var/www/domains.json:ro
- ./instance_a/userfiles:/var/www/userfiles:ro
# A stable, CA-signed cert (see frontend/.local/make_localhost.sh) covering localhost plus
# every loopback IP a dev proxy binds to below, instead of Dockerfile.proxy generating a
# fresh throwaway self-signed one on every image build - that regenerated cert invalidated
# any trust exception you'd added in your browser on the previous build. Trust
# frontend/.local/RootCA.crt once (see docs/development.md) and it keeps working across
# rebuilds.
- ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro
- ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353"
proxy-b:
build:
context: ./
dockerfile: Dockerfile.proxy
volumes:
- ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
- ./instance_b/userfiles:/var/www/userfiles:ro
- ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro
- ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro
ports:
- "127.0.0.2:8080:8080"
dns:
build:
context: ./
dockerfile: Dockerfile.dns
volumes:
- ./zone.json:/dns/zone.json
expose:
- 8053
networks:
default:
aliases:
- toolshed-dns

View file

@ -2,6 +2,8 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True
SERVE_X_ACCEL_REDIRECT=False
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'

View file

@ -1,6 +1,8 @@
events {}
http {
client_max_body_size 128M;
upstream backend {
server backend-a:8000;
}
@ -14,7 +16,7 @@ http {
}
upstream dns {
server dns:8053;
server toolshed-dns:8053;
}
server {
@ -45,8 +47,34 @@ http {
proxy_pass http://backend;
}
location /media {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /djangoadmin {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /docs {
proxy_pass http://backend/docs;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /static {
@ -80,17 +108,35 @@ http {
# DoH server
server {
listen 5353 ssl;
server_name localhost;
server_name localhost 127.0.0.3;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query {
proxy_pass http://dns;
# allow any origin
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
# Ensure CORS headers are present even when nginx generates 5xx responses.
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Expose-Headers' 'Content-Type' always;
error_page 500 502 503 504 = @doh_error;
location /dns-query {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain; charset=utf-8';
return 204;
}
proxy_pass http://dns;
}
location @doh_error {
default_type text/plain;
return 502 'DoH upstream unavailable';
}
}
}

View file

@ -0,0 +1,108 @@
import base64
def create_test_data():
print('Creating test data for instance A')
from authentication.models import ToolshedUser
admin = None
try:
if ToolshedUser.objects.filter(username='admin').exists():
admin = ToolshedUser.objects.get(username='admin')
else:
admin = ToolshedUser.objects.create_superuser('admin', 'admin@localhost', '')
admin.set_password('j7Th5TfCGUZmQ7U')
admin.save()
print('Created {}@{} with private key {} and public key {}'.format(admin.username, admin.domain,
admin.private_key,
admin.public_identity.public_key))
except Exception as e:
print('Admin user already exists, skipping')
from hostadmin.models import Domain
try:
domain1 = Domain.objects.create(name='localhost', owner=admin, open_registration=False)
domain1.save()
print('Created domain {} with closed registration'.format(domain1.name))
except Exception as e:
print('Domain localhost already exists, skipping')
domain2 = None
try:
if Domain.objects.filter(name='a.localhost').exists():
domain2 = Domain.objects.get(name='a.localhost')
domain2 = Domain.objects.create(name='a.localhost', owner=admin, open_registration=True)
domain2.save()
print('Created domain {} with open registration'.format(domain2.name))
except Exception as e:
print('Domain b.localhost already exists, skipping')
user = None
try:
if ToolshedUser.objects.filter(username='test_a').exists():
user = ToolshedUser.objects.get(username='test_a')
user = ToolshedUser.objects.create_user('test_a', 'testa@example.com', '', domain=domain2.name,
private_key='4ab79601edc400fd0263c7d5fd045ea7f5de28f1dd8905e2479f96893f3257d8')
user.set_password('test_a')
user.save()
print('Created user {}@{} with private key {} and public key {}'.format(user.username, user.domain,
user.private_key,
user.public_identity.public_key))
except Exception as e:
print('User foobar already exists, skipping')
from authentication.models import KnownIdentity
identity = None
try:
if KnownIdentity.objects.filter(username='test_b').exists():
identity = KnownIdentity.objects.get(username='test_b')
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c')
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
identity.save()
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
identity.public_key))
except Exception as e:
print('Identity already exists, skipping')
from toolshed.models import Category
category1 = None
try:
if Category.objects.filter(name='Test Category').exists():
category1 = Category.objects.get(name='Test Category')
category1 = Category.objects.get_or_create(name='Test Category', origin='test')[0]
except Exception as e:
print('Category already exists, skipping')
from toolshed.models import InventoryItem
item1 = None
try:
if InventoryItem.objects.filter(name='Test Item').exists():
item1 = InventoryItem.objects.get(name='Test Item')
item1 = InventoryItem.objects.create(name='Test Item', description='This is a test item', category=category1,
availability_policy='rent', owned_quantity=1, owner=user)
item1.save()
except Exception as e:
print('Item already exists, skipping')
try:
item2 = InventoryItem.objects.create(name='Test Item 2', description='This is a test item', category=category1,
availability_policy='share', owned_quantity=1, owner=user)
item2.save()
print('Created test items with IDs "{}" and "{}"'.format(item1.id, item2.id))
except Exception as e:
print('Item already exists, skipping')
try:
user.friends.add(identity)
print('Added identity {} to friends list of user {}'.format(identity.username, user.username))
except Exception as e:
print('Identity already in friends list, skipping')
from files.models import File
try:
file1 = File.objects.create(mime_type='text/plain', data=base64.b64encode(b'testcontent1').decode('utf-8'))
file1.save()
print(f'Created file at /{file1.hash[:2]}/{file1.hash[2:4]}/{file1.hash[4:6]}/{file1.hash[6:]}')
except Exception as e:
print('File already exists, skipping')

View file

@ -1,6 +1,8 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True
SERVE_X_ACCEL_REDIRECT=True
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'

View file

@ -1,6 +1,8 @@
events {}
http {
client_max_body_size 128M;
upstream backend {
server backend-b:8000;
}
@ -35,6 +37,28 @@ http {
proxy_pass http://backend;
}
location /media {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /redirect_media/ {
internal;
alias /var/www/userfiles/;
# This location serves the file directly, bypassing Django (and therefore
# django-cors-headers) entirely - it's the target of the X-Accel-Redirect
# response from files/media_urls.py, used when SERVE_X_ACCEL_REDIRECT=True.
# CORS headers must be added here explicitly since nothing else will.
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Accept, Content-Type, Origin, User-Agent' always;
}
location /docs {
proxy_pass http://backend/docs;
}

View file

@ -0,0 +1,108 @@
import base64
def create_test_data():
print('Creating test data for instance B')
from authentication.models import ToolshedUser
admin = None
try:
if ToolshedUser.objects.filter(username='admin').exists():
admin = ToolshedUser.objects.get(username='admin')
else:
admin = ToolshedUser.objects.create_superuser('admin', 'admin@localhost', '')
admin.set_password('j7Th5TfCGUZmQ7U')
admin.save()
print('Created {}@{} with private key {} and public key {}'.format(admin.username, admin.domain,
admin.private_key,
admin.public_identity.public_key))
except Exception as e:
print('Admin user already exists, skipping')
from hostadmin.models import Domain
try:
domain1 = Domain.objects.create(name='localhost', owner=admin, open_registration=False)
domain1.save()
print('Created domain {} with closed registration'.format(domain1.name))
except Exception as e:
print('Domain localhost already exists, skipping')
domain2 = None
try:
if Domain.objects.filter(name='b.localhost').exists():
domain2 = Domain.objects.get(name='b.localhost')
domain2 = Domain.objects.create(name='b.localhost', owner=admin, open_registration=True)
domain2.save()
print('Created domain {} with open registration'.format(domain2.name))
except Exception as e:
print('Domain b.localhost already exists, skipping')
user = None
try:
if ToolshedUser.objects.filter(username='test_b').exists():
user = ToolshedUser.objects.get(username='test_b')
user = ToolshedUser.objects.create_user('test_b', 'testb@example.com', '', domain=domain2.name,
private_key='2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40')
user.set_password('test_b')
user.save()
print('Created user {}@{} with private key {} and public key {}'.format(user.username, user.domain,
user.private_key,
user.public_identity.public_key))
except Exception as e:
print('User foobar already exists, skipping')
from authentication.models import KnownIdentity
identity = None
try:
if KnownIdentity.objects.filter(username='test_a').exists():
identity = KnownIdentity.objects.get(username='test_a')
identity = KnownIdentity.objects.create(username='test_a', domain='a.localhost',
public_key='cdcf6f1897b0ab3a123047d9b707d4123f01daf41921dbe787d1d0697f0ee42b')
# pk '4ab79601edc400fd0263c7d5fd045ea7f5de28f1dd8905e2479f96893f3257d8'
identity.save()
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
identity.public_key))
except Exception as e:
print('Identity already exists, skipping')
from toolshed.models import Category
category1 = None
try:
if Category.objects.filter(name='Test Category').exists():
category1 = Category.objects.get(name='Test Category')
category1 = Category.objects.get_or_create(name='Test Category', origin='test')[0]
except Exception as e:
print('Category already exists, skipping')
from toolshed.models import InventoryItem
item1 = None
try:
if InventoryItem.objects.filter(name='Test Item 3').exists():
item1 = InventoryItem.objects.get(name='Test Item 3')
item1 = InventoryItem.objects.create(name='Test Item 3', description='This is a test item', category=category1,
availability_policy='sell', owned_quantity=1, owner=user)
item1.save()
except Exception as e:
print('Item already exists, skipping')
try:
item2 = InventoryItem.objects.create(name='Test Item 4', description='This is a test item', category=category1,
availability_policy='lend', owned_quantity=1, owner=user)
item2.save()
print('Created test items with IDs "{}" and "{}"'.format(item1.id, item2.id))
except Exception as e:
print('Item already exists, skipping')
try:
user.friends.add(identity)
print('Added identity {} to friends list of user {}'.format(identity.username, user.username))
except Exception as e:
print('Identity already in friends list, skipping')
from files.models import File
try:
file1 = File.objects.create(mime_type='text/plain', data=base64.b64encode(b'testcontent1').decode('utf-8'))
file1.save()
print(f'Created file at /{file1.hash[:2]}/{file1.hash[2:4]}/{file1.hash[4:6]}/{file1.hash[6:]}')
except Exception as e:
print('File already exists, skipping')

View file

@ -21,4 +21,4 @@
"target": "127.0.0.2."
}
}
]
]

View file

@ -1,78 +0,0 @@
version: '3.8'
services:
backend-a:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_a/a.env:/code/.env
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_b/b.env:/code/.env
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend:
build:
context: ../frontend/
dockerfile: ../deploy/dev/Dockerfile.frontend
volumes:
- ../frontend:/app:ro
- /app/node_modules
expose:
- 5173
command: npm run dev -- --host
wiki:
build:
context: ../
dockerfile: deploy/dev/Dockerfile.wiki
volumes:
- ../mkdocs.yml:/wiki/mkdocs.yml
- ../docs:/wiki/docs
expose:
- 8001
command: mkdocs serve --dev-addr=0.0.0.0:8001
proxy-a:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353"
proxy-b:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
ports:
- "127.0.0.2:8080:8080"
dns:
build:
context: ./dev/
dockerfile: Dockerfile.dns
volumes:
- ./dev/zone.json:/dns/zone.json
expose:
- 8053

3
deploy/prod/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.secrets/
inventory.yml
.frontend-build/

View file

@ -0,0 +1,33 @@
# Production image for the Django backend.
# Runs migrations then serves the app with gunicorn on port 8000.
# Static files are collected at build time into /app/staticfiles and
# served by the backend itself behind the host nginx reverse proxy.
FROM python:3.11-slim
# The build context here is just backend/ (no .git), so settings.py's own
# `git rev-parse` fallback can't find a repo - the actual commit is passed
# in from the real checkout via this build-arg instead (see playbook.yml).
ARG GIT_COMMIT=unknown
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=backend.settings \
GIT_COMMIT=$GIT_COMMIT
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
# collectstatic only needs Django settings to import cleanly, not a real
# secret; the actual SECRET_KEY is injected at container runtime via
# --env-file and overrides this.
RUN SECRET_KEY=build-time-placeholder python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 --workers 3"]

View file

@ -0,0 +1,26 @@
# Build-only image for the Vue frontend.
# It is never run as a service: ansible builds this image once, runs it
# with the host output directory bind-mounted at /output, the container
# copies the compiled static build into it, and exits. Nginx on the host
# then serves that directory directly.
FROM node:20-alpine AS build
WORKDIR /app
# The build context here is just frontend/ (no .git), so vite.config.js's
# own `git rev-parse` fallback can't find a repo - the actual commit is
# passed in from the real checkout via this build-arg instead (see
# playbook.yml).
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
COPY package.json package-lock.json ./
COPY extras/ ./extras/
RUN npm ci
COPY . .
RUN npm run build
FROM alpine AS export
COPY --from=build /app/dist /dist
VOLUME /output
CMD ["sh", "-c", "rm -rf /output/* && cp -a /dist/. /output/"]

View file

@ -0,0 +1,17 @@
# Build-only image for the project wiki (mkdocs).
# It is never run as a service: ansible builds this image once, runs it
# with the host output directory bind-mounted at /output, the container
# copies the built static site into it, and exits. Nginx on the host
# then serves that directory directly, the same way it does the frontend.
FROM python:3.11-slim AS build
WORKDIR /wiki
RUN pip install --no-cache-dir mkdocs
COPY mkdocs.yml ./
COPY docs/ ./docs/
RUN mkdocs build
FROM alpine AS export
COPY --from=build /wiki/site /site
VOLUME /output
CMD ["sh", "-c", "rm -rf /output/* && cp -a /site/. /output/"]

224
deploy/prod/README.md Normal file
View file

@ -0,0 +1,224 @@
# Toolshed production deployment — manual steps
`playbook.yml` automates installing docker.io and nginx (plus certbot, and
obtaining/renewing a TLS certificate with it, on hosts that manage their own
— see `behind_tls_proxy` below), building the backend, frontend and wiki
images, exporting the frontend/wiki static builds and the backend's
`collectstatic` output for nginx to serve directly (nginx also serves
user-uploaded files directly, via an X-Accel-Redirect Django issues after its
own permission check — see `location /redirect_media/` in `playbook.yml`),
writing the small `/local/domains` and `/local/dns` fixture files the
frontend fetches directly (registration domain list and DoH resolver
preference — see `toolshed_register_domains`/`toolshed_doh_resolvers` in
`playbook.yml`), configuring nginx, and installing the `toolshed-backend`
systemd service. It does **not** set up the target server or DNS. Those are
manual, one-time steps and are covered here. Seeding the backend's shared
reference data is also a manual, one-time step — see
[First superuser & shared reference data](#5-first-superuser--shared-reference-data).
## 1. Server & firewall
- A Debian/Ubuntu host reachable over SSH.
- Copy `inventory.example.yml` to `inventory.yml` (git-ignored, since it
holds real hostnames/IPs) and fill in your host(s) — see
[Per-deployment configuration](#2-per-deployment-configuration).
- Inbound TCP 80 open in the firewall/security group. Also open 443 unless
`behind_tls_proxy: true` — and keep both open permanently, not just for the
initial deploy: certbot's renewal timer needs 80 for the ACME HTTP-01
challenge and 443 for HTTPS traffic for as long as this host is live.
## 2. Per-deployment configuration
Each entry under `hosts:` in `inventory.yml` is its own independent
deployment (its own repo checkout, database, domain, systemd service and
Django `SECRET_KEY` — nothing is shared between hosts). Set these as
host_vars directly on each host entry, not via `-e` on the command line,
so a single `inventory.yml` can hold several unrelated deployments safely:
```yaml
toolshed:
hosts:
my-server:
ansible_host: 203.0.113.10
ansible_user: deploy
toolshed_domain: toolshed.webdomain.tld
toolshed_handle_domain: yourtoolshed.tld # optional, see below
toolshed_repo_url: git@example.com:your-org/toolshed.git
behind_tls_proxy: false
```
- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django
`ALLOWED_HOSTS`, and the hostname(s) you'll point a TLS cert at — e.g.
`toolshed.webdomain.tld`. Required, no default. May be a single domain (as
above) or a list, e.g. to also answer on a `www.` alias:
```yaml
toolshed_domain:
- toolshed.webdomain.tld
- www.toolshed.webdomain.tld
```
The Let's Encrypt certificate covers all of them, named on disk after
whichever one is listed first. This is not necessarily the same as the
**handle domain** your users log in with (the part after `@` in
`user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate.
- `toolshed_handle_domain` — the **handle domain**, only needed when it's
different from `toolshed_domain`. Omit it when the two are the same (it
then defaults to `toolshed_domain`). Like `toolshed_domain`, it may be a
single domain or a list, e.g. if this deployment accepts registrations for
more than one handle domain. It doesn't affect nginx/Django at all (they
only ever accept `toolshed_domain` as the `Host` header) — it's used
solely to populate the `/local/domains` registration fixture (see
`toolshed_register_domains` in `playbook.yml`); publishing the SRV record for
each handle domain is a separate, manual DNS step either way.
- `toolshed_repo_url` — the git remote the playbook checks out and builds
from. Required, no default.
- `toolshed_version` — the branch, tag or commit to check out and build.
Optional, defaults to `stable`.
- `behind_tls_proxy``true` if TLS for this host is already terminated by
something in front of it (e.g. an external reverse proxy or load
balancer) that forwards plain HTTP here; `false` if this nginx has to
terminate TLS itself. This controls two things:
- Whether nginx trusts an upstream `X-Forwarded-Proto` header or sets its
own — get this wrong and Django's `SECURE_PROXY_SSL_HEADER` check
(`backend/backend/settings.py`) will treat every request as insecure or,
flipped the other way, treat plain HTTP as secure.
- Whether the playbook manages TLS at all. When `false`, it automatically
obtains a Let's Encrypt certificate via certbot and switches nginx over
to it — nothing to do manually beyond DNS (below). certbot's own systemd
timer keeps renewing it afterwards, independent of the playbook.
- `toolshed_letsencrypt_email` — required whenever `behind_tls_proxy` is
`false`; the account email certbot registers the certificate under
(used only for renewal-failure notices). Ignored otherwise.
- `http_port` — optional, defaults to `80`. Only relevant when
`behind_tls_proxy: true` and whatever's in front of this host forwards to
a nonstandard port instead of 80.
- `doh_resolvers` — optional, defaults to `["1.1.1.1", "8.8.8.8"]` (the same
hardcoded fallback the frontend itself uses, see `frontend/src/dns.js`).
DNS-over-HTTPS resolvers the frontend uses to look up a handle domain's
`_toolshed-server._tcp` SRV record before it has a cached preference.
Written to `/local/dns` at deploy time; only worth overriding as a
host_var (or `-e doh_resolvers='["9.9.9.9"]'`) if you want this
deployment to prefer a specific resolver.
## 3. DNS
There are two distinct domains at play here, and it's easy to conflate them:
- **Web domain** — the machine's actual hostname: nginx `server_name`,
Django `ALLOWED_HOSTS`, your TLS cert, what's in `toolshed_domain`. This is
what an A/AAAA record has to resolve to the server's IP for.
- **Handle domain** — the part after the `@` in a username, e.g.
`user@yourtoolshed.tld`. Toolshed usernames don't encode a server address
directly; the frontend resolves the handle domain to a server via an SRV
record, `_toolshed-server._tcp.<handle domain>.` (see
`frontend/src/store.js`, `lookupServer`), which always points at the web
domain — nginx/Django never see the handle domain as a `Host` header.
`toolshed_handle_domain` (see [Per-deployment
configuration](#2-per-deployment-configuration)) only feeds the
`/local/domains` registration fixture; publishing the actual SRV record is
still a separate, manual DNS step, covered below.
The SRV lookup happens for every login, not just federation with other
servers, so **every** deployment needs it published for its own handle
domain — even a standalone server that only ever serves itself.
These two domains can be **the same** or **completely different**, and
that's exactly the choice between an A record and an SRV record:
- **Same domain**: if `yourtoolshed.tld` is both the web domain and the
handle domain, it needs both an A record (so the domain itself resolves to
the server) and an SRV record that happens to point back at itself.
- **Different domains**: the handle domain only needs the SRV record — no A
record of its own — pointing at whatever web domain the server actually
lives at. This is useful when the handle you give out (short, brandable,
independent of hosting) shouldn't have to match wherever the box is
actually deployed (a subdomain of a shared hosting provider, an internal
service name, etc.).
**a) A/AAAA record — web domain → server IP:**
```sh
dig <your-web-domain> A
```
**b) SRV record — handle domain → web domain + port.** Use port 443: the
federation protocol is HTTPS-only.
```sh
dig _toolshed-server._tcp.<your-handle-domain> SRV
```
For example, with a handle domain of `yourtoolshed.tld` and a web domain of
`toolshed.webdomain.tld`:
```
$ dig _toolshed-server._tcp.yourtoolshed.tld srv
_toolshed-server._tcp.yourtoolshed.tld. 300 IN SRV 10 10 443 toolshed.webdomain.tld.
$ dig toolshed.webdomain.tld A
toolshed.webdomain.tld. 300 IN A 203.0.113.10
```
If you instead want `yourtoolshed.tld` itself to be the web domain too, its
SRV record just points at itself (`... SRV 10 10 443 yourtoolshed.tld.`) and
it additionally needs its own A record.
## 4. Secrets
`toolshed_secret_key` is generated once per host by the playbook (via the
`password` lookup, keyed by the host's inventory name) and stored as
`.secrets/<inventory-hostname>_secret_key` on the *control* machine, not on
the target. Back these files up — losing one invalidates all sessions and
signed cookies for that deployment on its next redeploy. They're git-ignored
on purpose; never commit them.
## 5. First superuser & shared reference data
The production backend image only runs `migrate` and `collectstatic` at
startup (see `Dockerfile.backend`) — unlike the dev compose setup, it never
runs the interactive `configure.py`. Two things dev gets "for free" from that
script therefore need doing manually, once, after a host's backend container
is first up (run these on the target host itself, or prefix with
`ssh <that-host>`):
- **Superuser account:**
```sh
docker exec -it toolshed-backend python manage.py createsuperuser
```
- **Shared reference data** (the standard categories/properties/tags
shipped in `backend/shared_data/*.json` — tools, electrical, screws, IT,
etc.): without this step a fresh deployment starts with none of them.
Run `configure.py` interactively (the `-it` flags matter — the script's
prompts only appear with a real tty) and answer "yes" when it asks to
import them:
```sh
docker exec -it toolshed-backend python configure.py
```
The other prompts it asks first (create `.env`, create a database) are
harmless to answer "yes" to as well: the container already gets its real
`SECRET_KEY`/`ALLOWED_HOSTS`/db path from the environment (the systemd unit
passes them via `--env-file`, see the "Write backend environment file" task
in `playbook.yml`), those checks just look for files at paths relative to
`/app` that don't exist in this container, and re-running `migrate` against
the real database is idempotent. You can say "no" to the superuser prompt
here if you already created one above.
## 6. Running the playbook
Always target one host at a time with `--limit` — running against the whole
`toolshed` group in one invocation would apply every host's own
`toolshed_domain`/`toolshed_repo_url` correctly (they're per-host vars, see
[Per-deployment configuration](#2-per-deployment-configuration)), but rolls
out all deployments back-to-back in one run, which is rarely what you want:
```sh
ansible-playbook -i inventory.yml playbook.yml --limit my-server
```
Re-run it to roll out a new version to that host. It deploys whatever
`toolshed_version` is set for that host (`stable` by default) — set the
host_var for a persistent change, or pass `-e toolshed_version=<branch/tag/commit>`
for a one-off deploy of something else.

View file

@ -0,0 +1,53 @@
---
# Copy this file to inventory.yml (git-ignored) and fill in your real
# hosts. Each entry under hosts: is an independent deployment - see the
# README's "Per-deployment configuration" section for what each var means.
toolshed:
hosts:
my-server:
ansible_host: 203.0.113.10
ansible_user: deploy
# toolshed_domain is the "web domain" - see the README's DNS section
# for how this relates to the separate "handle domain" your users
# log in with (user@yourtoolshed.tld). May be a single domain (as
# here) or a list, e.g. to also answer on a "www." alias:
# toolshed_domain:
# - toolshed.webdomain.tld
# - www.toolshed.webdomain.tld
# The Let's Encrypt certificate is requested for all of them, named
# after whichever one is listed first.
toolshed_domain: toolshed.webdomain.tld
# Optional - only needed if the handle domain differs from the web
# domain above. Omit it entirely when they're the same. Like
# toolshed_domain, this may be a single domain or a list, e.g. if this
# deployment accepts registrations for more than one handle domain:
# toolshed_handle_domain:
# - yourtoolshed.tld
# - alt.yourtoolshed.tld
toolshed_handle_domain: yourtoolshed.tld
toolshed_repo_url: git@example.com:your-org/toolshed.git
# Optional - branch, tag or commit to deploy. Defaults to "stable".
toolshed_version: stable
# true if something in front of this host already terminates TLS
# (reverse proxy/load balancer), false if this nginx must do it itself.
behind_tls_proxy: false
# Required whenever behind_tls_proxy is false: the playbook obtains
# its own Let's Encrypt certificate via certbot, which needs an
# account email for renewal notices.
toolshed_letsencrypt_email: admin@example.com
# A second, unrelated deployment behind an existing TLS-terminating
# proxy - remove this if you only run one instance. Here the handle
# domain and web domain are the same, so toolshed_handle_domain is
# simply omitted, and toolshed_letsencrypt_email isn't needed since
# this nginx never handles TLS itself.
my-other-server:
ansible_host: my-other-server.example.com
ansible_user: deploy
toolshed_domain: toolshed.example.com
toolshed_repo_url: git@example.com:your-org/toolshed.git
behind_tls_proxy: true
# Only needed if the proxy in front forwards to something other than
# port 80 on this host.
http_port: 8080

677
deploy/prod/playbook.yml Normal file
View file

@ -0,0 +1,677 @@
---
# Production deploy for toolshed.
#
# - installs docker.io and nginx on the target (plus certbot, unless
# behind_tls_proxy is true)
# - checks out the source and builds the backend and frontend docker images
# - runs the frontend image once to export its static build, which nginx
# then serves directly (the frontend image is never run as a service)
# - configures nginx (inline template, no separate .conf file) and, unless
# behind_tls_proxy is true, obtains/renews a Let's Encrypt certificate via
# certbot and switches nginx over to it automatically - no manual TLS step
# - installs and manages a systemd service that runs the backend container
#
# Usage (each host is its own independent deployment - always target one
# at a time, never the whole "toolshed" group in one run):
# ansible-playbook -i inventory.yml playbook.yml --limit my-server
#
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional,
# either may be a single domain or a list of domains), toolshed_version
# (optional, defaults to "stable"), behind_tls_proxy and
# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are
# per-deployment and must be set as host_vars in inventory.yml (copy
# inventory.example.yml) rather than here or via -e, so that each host in
# the "toolshed" group can point at its own repo/domain/branch. They're read
# with `mandatory`/`default()` below instead of being declared in play
# `vars:`, since play vars always take precedence over inventory host_vars
# and would otherwise silently override whatever is set per-host.
- name: Deploy toolshed
hosts: toolshed
become: true
vars:
toolshed_src_dir: /opt/toolshed/src
toolshed_data_dir: /opt/toolshed/data
toolshed_dist_dir: /var/www/toolshed
toolshed_backend_image: toolshed-backend
toolshed_frontend_image: toolshed-frontend-builder
toolshed_wiki_image: toolshed-wiki-builder
toolshed_backend_container: toolshed-backend
toolshed_backend_port: 8000
toolshed_wiki_dist_dir: /var/www/toolshed-wiki
toolshed_local_dir: /var/www/toolshed-local
# The frontend build runs on the controller (see "Build frontend builder
# docker image (controller)" below) rather than the target host, so its
# scratch checkout and build output live here instead of under
# toolshed_src_dir/toolshed_dist_dir. Keyed by inventory_hostname so
# concurrent deploys to different hosts never collide.
toolshed_frontend_build_src_dir: "{{ playbook_dir }}/.frontend-build/{{ inventory_hostname }}/src"
toolshed_frontend_build_dist_dir: "{{ playbook_dir }}/.frontend-build/{{ inventory_hostname }}/dist"
# Django's collectstatic output (admin/drf-yasg assets etc.), exported
# from the built backend image so nginx can serve it directly instead of
# proxying to gunicorn for every asset request.
toolshed_static_dir: /var/www/toolshed-static
# Domain(s) this server accepts registrations for (the "handle domain" -
# see the README's DNS section). toolshed_handle_domain may be a single
# domain or a list; when unset it falls back to toolshed_domain (whole
# list, if that's a list too). Served as a static /local/domains fixture
# that the frontend's registration/pairing forms fetch to populate their
# domain dropdown (frontend/src/views/Register.vue, Pairing.vue) -
# without it that dropdown is just empty.
toolshed_handle_domain_or_default: "{{ toolshed_handle_domain | default(toolshed_domain) }}"
toolshed_register_domains: >-
{{ ([toolshed_handle_domain_or_default]
if toolshed_handle_domain_or_default is string
else toolshed_handle_domain_or_default) | unique }}
# DoH resolvers the frontend falls back to for SRV lookups when it has
# no cached preference yet, served as a static /local/dns fixture. These
# match the frontend's own hardcoded fallback (frontend/src/dns.js), so
# this mostly makes the choice explicit and per-host overridable (e.g.
# -e doh_resolvers='["9.9.9.9"]') rather than changing behavior.
toolshed_doh_resolvers: "{{ doh_resolvers | default(['1.1.1.1', '8.8.8.8']) }}"
# Docker tags can't contain "/", but toolshed_version is a git ref and
# branch names like "jedi/proto/frontend" do - sanitize before using it
# as an image tag. The raw value is still used as-is for the actual git
# checkout, where slashes are fine.
toolshed_image_tag: "{{ (toolshed_version | default('stable')) | replace('/', '-') }}"
toolshed_debug: "False"
# Plain HTTP listen port. Only relevant behind an external proxy that
# forwards to something other than 80 (see http_port in inventory.yml);
# when this nginx terminates TLS itself, the public port is always 443.
toolshed_http_port: "{{ http_port | default(80) }}"
toolshed_letsencrypt_webroot: /var/www/letsencrypt
# Nginx sets its own X-Forwarded-Proto from $scheme when it terminates
# TLS itself. Behind an external TLS-terminating proxy, $scheme at this
# nginx is always "http" (the proxy already stripped TLS one hop
# earlier), so overwriting the header with $scheme would tell Django
# every request is insecure. In that case pass through the proxy's own
# header instead.
toolshed_x_forwarded_proto: >-
{{ '$http_x_forwarded_proto' if (behind_tls_proxy | default(false) | bool) else '$scheme' }}
# Only the web domain (toolshed_domain) - nginx server_name, Django
# ALLOWED_HOSTS, and the cert certbot requests. The handle domain
# (toolshed_handle_domain) is resolved by clients via its own SRV record
# and doesn't necessarily have an A record pointing at this host at all
# (see the README's DNS section), so it can't reliably serve an HTTP-01
# challenge or ever show up as this nginx's Host header.
#
# toolshed_domain may be a single domain or a list (e.g. a bare domain
# plus a "www." alias). certbot names the Let's Encrypt certificate's
# live/ directory after whichever domain is passed first via -d, so
# toolshed_hostnames[0] (below) is used wherever the playbook needs to
# reference that directory by name.
toolshed_domain_checked: >-
{{ toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname) }}
toolshed_hostnames: >-
{{ ([toolshed_domain_checked]
if toolshed_domain_checked is string
else toolshed_domain_checked) | unique }}
# Generated once per host on the controller and reused on every
# subsequent run against that host, keyed by inventory_hostname so
# separate deployments never end up sharing a Django SECRET_KEY.
toolshed_secret_key: >-
{{ lookup('ansible.builtin.password',
playbook_dir ~ '/.secrets/' ~ inventory_hostname ~ '_secret_key length=64 chars=ascii_letters,digits') }}
# Rendered twice against the same var (see the tasks below): once before
# a certificate exists (serves the site plainly over toolshed_http_port,
# or over 80/plain-HTTP forever if behind_tls_proxy), and once after
# certbot has obtained one, at which point the plain HTTP vhost switches
# to a redirect and a 443 vhost with the real content appears. Whichever
# of those two states applies, toolshed_cert (a registered `stat` result,
# undefined/false until it's checked) decides which one renders - this
# is the "another nginx config" from a single inline template, driven by
# behind_tls_proxy and certificate state rather than a separate file.
toolshed_nginx_conf: |
upstream toolshed_backend {
server 127.0.0.1:{{ toolshed_backend_port }};
}
{% macro toolshed_locations() %}
location /api {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }};
proxy_pass http://toolshed_backend;
}
location /auth {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }};
proxy_pass http://toolshed_backend;
}
# Django (SignatureAuthentication + per-file friend/owner checks,
# see files/media_urls.py) decides whether the request is allowed
# at all; it never streams the bytes itself here (SERVE_X_ACCEL_REDIRECT
# is on), it just answers with an X-Accel-Redirect to the internal
# location below, which nginx follows and serves directly from disk.
location /media {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }};
proxy_pass http://toolshed_backend;
}
# Only reachable via the X-Accel-Redirect above, never directly by
# clients (`internal`) - this is what makes it safe for nginx to
# serve these bytes itself without reimplementing the access
# checks Django already did in the /media location.
location /redirect_media/ {
internal;
alias {{ toolshed_data_dir }}/userfiles/;
# Django would normally set this itself (CORS_ALLOW_ALL_ORIGINS,
# see settings.py) but never gets to run for a request nginx
# serves directly - see the comment in files/media_urls.py.
add_header Access-Control-Allow-Origin * always;
}
location /djangoadmin {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }};
proxy_pass http://toolshed_backend;
}
location /docs {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }};
proxy_pass http://toolshed_backend;
}
location /static/ {
alias {{ toolshed_static_dir }}/;
try_files $uri =404;
}
location /wiki/ {
alias {{ toolshed_wiki_dist_dir }}/;
try_files $uri $uri/ =404;
}
location = /wiki {
return 301 /wiki/;
}
# Static fixtures the frontend fetches directly (registration
# domain list, DoH resolver preference) - see toolshed_register_domains
# and toolshed_doh_resolvers above.
location /local/ {
alias {{ toolshed_local_dir }}/;
try_files $uri.json =404;
add_header Content-Type application/json;
}
# Vue-router history mode: fall back to index.html for
# any path that isn't a real static file.
location / {
try_files $uri $uri/ /index.html;
}
{% endmacro %}
{% if behind_tls_proxy | default(false) | bool %}
server {
listen {{ toolshed_http_port }};
listen [::]:{{ toolshed_http_port }};
server_name {{ toolshed_hostnames | join(' ') }};
client_max_body_size 128M;
root {{ toolshed_dist_dir }};
index index.html;
{{ toolshed_locations() }}
}
{% else %}
{% set tls_active = toolshed_cert.stat.exists | default(false) %}
server {
listen {{ toolshed_http_port }};
listen [::]:{{ toolshed_http_port }};
server_name {{ toolshed_hostnames | join(' ') }};
location /.well-known/acme-challenge/ {
root {{ toolshed_letsencrypt_webroot }};
}
{% if tls_active %}
location / {
return 301 https://$host$request_uri;
}
{% else %}
client_max_body_size 128M;
root {{ toolshed_dist_dir }};
index index.html;
{{ toolshed_locations() }}
{% endif %}
}
{% if tls_active %}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ toolshed_hostnames | join(' ') }};
ssl_certificate /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/privkey.pem;
client_max_body_size 128M;
root {{ toolshed_dist_dir }};
index index.html;
{{ toolshed_locations() }}
}
{% endif %}
{% endif %}
tasks:
- name: Install docker.io, nginx and rsync
ansible.builtin.apt:
name:
- docker.io
- nginx
# rsync is what the frontend dist sync (further down) relies on -
# it's the ansible.posix.synchronize module's transport.
- rsync
state: present
update_cache: true
- name: Install certbot
ansible.builtin.apt:
name: certbot
state: present
when: not (behind_tls_proxy | default(false) | bool)
- name: Ensure docker is running and enabled
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Ensure nginx is running and enabled
ansible.builtin.systemd:
name: nginx
state: started
enabled: true
- name: Checkout toolshed source
ansible.builtin.git:
repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}"
dest: "{{ toolshed_src_dir }}"
version: "{{ toolshed_version | default('stable') }}"
force: true
# frontend/extras is registered as a submodule but unused and its
# pinned commit isn't fetchable from upstream - don't let a broken
# submodule block the checkout.
recursive: false
register: toolshed_checkout
- name: Create toolshed system user
ansible.builtin.user:
name: toolshed
system: true
shell: /usr/sbin/nologin
home: "{{ toolshed_data_dir }}"
create_home: false
register: toolshed_user
- name: Create backend data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: toolshed
group: toolshed
mode: "0750"
loop:
- "{{ toolshed_data_dir }}"
- "{{ toolshed_data_dir }}/userfiles"
# nginx's `location /redirect_media/` (below) reads user-uploaded files
# straight off disk as www-data - group membership plus the 0750 mode
# above/FILE_UPLOAD_PERMISSIONS (backend/backend/settings.py) is what
# makes that readable without loosening it to world-readable.
- name: Allow nginx to read backend user files
ansible.builtin.user:
name: www-data
groups: toolshed
append: true
# New group membership only takes effect for processes started (or
# forked) after this - nginx's already-running workers won't see it
# until reloaded.
notify: reload nginx
- name: Create frontend static output directory
ansible.builtin.file:
path: "{{ toolshed_dist_dir }}"
state: directory
owner: "www-data"
group: "www-data"
mode: "0750"
- name: Create backend static output directory
ansible.builtin.file:
path: "{{ toolshed_static_dir }}"
state: directory
owner: www-data
group: www-data
mode: "0750"
- name: Write backend environment file
ansible.builtin.copy:
dest: "{{ toolshed_data_dir }}/backend.env"
# Root-owned and unreadable by the toolshed user on purpose: this is
# read by the docker daemon (root) via --env-file at container
# start and injected directly as env vars, so the containerized app
# - which runs as the toolshed user, see the systemd unit below -
# never needs filesystem access to its own SECRET_KEY.
owner: root
group: root
mode: "0600"
content: |
DEBUG={{ toolshed_debug }}
SECRET_KEY={{ toolshed_secret_key }}
ALLOWED_HOSTS={{ toolshed_hostnames | join(',') }}
SERVE_X_ACCEL_REDIRECT=True
TOOLSHED_DB_PATH=/data/db.sqlite3
TOOLSHED_USERFILES_PATH=/data/userfiles
notify: restart backend
- name: Build backend docker image
ansible.builtin.command:
cmd: >-
docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }}
--build-arg GIT_COMMIT={{ toolshed_checkout.after[:7] }}
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend
changed_when: true
notify: restart backend
- name: Tag backend image as latest
ansible.builtin.command:
cmd: docker tag {{ toolshed_backend_image }}:{{ toolshed_image_tag }} {{ toolshed_backend_image }}:latest
changed_when: true
notify: restart backend
# Dockerfile.backend runs collectstatic at build time, baking the result
# into the image at /app/staticfiles - copy it out to the host so nginx
# can serve it directly instead of proxying every asset request to
# gunicorn. No Django settings/DB access needed, so this can run as a
# one-off command against the image rather than the container.
- name: Export backend static files
ansible.builtin.command:
cmd: >-
docker run --rm -v {{ toolshed_static_dir }}:/output
{{ toolshed_backend_image }}:latest
sh -c "cp -a /app/staticfiles/. /output/"
changed_when: true
- name: Fix ownership of exported backend static files
ansible.builtin.file:
path: "{{ toolshed_static_dir }}"
owner: www-data
group: www-data
recurse: true
- name: Install systemd unit for the backend container
ansible.builtin.copy:
dest: /etc/systemd/system/toolshed-backend.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Toolshed backend (Django) container
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
TimeoutStartSec=0
Restart=always
ExecStartPre=-/usr/bin/docker stop {{ toolshed_backend_container }}
ExecStartPre=-/usr/bin/docker rm {{ toolshed_backend_container }}
ExecStart=/usr/bin/docker run --rm --name {{ toolshed_backend_container }} \
--user {{ toolshed_user.uid }}:{{ toolshed_user.group }} \
--env-file {{ toolshed_data_dir }}/backend.env \
-v {{ toolshed_data_dir }}:/data \
-p 127.0.0.1:{{ toolshed_backend_port }}:8000 \
{{ toolshed_backend_image }}:latest
ExecStop=/usr/bin/docker stop {{ toolshed_backend_container }}
[Install]
WantedBy=multi-user.target
notify: restart backend
# Installed before the bootstrap nginx flush_handlers below (which
# flushes every pending handler, not just reload nginx) - otherwise a
# fresh host would flush "restart backend" before this unit file exists
# and fail with "Could not find the requested service".
- name: Ensure toolshed-backend service is enabled and started
ansible.builtin.systemd:
name: toolshed-backend
daemon_reload: true
enabled: true
state: started
# The next few tasks build the frontend on the controller instead of the
# target host: `npm run build` pulls in bootstrap+jquery+vue+moment+
# js-nacl+qrcode, and esbuild's rendering/minification pass for that
# bundle needs more memory than small/memory-constrained target hosts
# (e.g. LXC containers without usable swap) reliably have. Only the
# resulting static dist/ is shipped to the target - the docker image
# itself never runs there. This assumes docker is already usable on the
# controller (not managed by this playbook, since "Install docker.io,
# nginx and rsync" above targets the remote host only).
- name: Checkout toolshed source (controller, for frontend build)
ansible.builtin.git:
repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}"
dest: "{{ toolshed_frontend_build_src_dir }}"
version: "{{ toolshed_version | default('stable') }}"
force: true
recursive: false
register: toolshed_frontend_checkout
delegate_to: localhost
become: false
- name: Build frontend builder docker image (controller)
ansible.builtin.command:
cmd: >-
docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
--build-arg GIT_COMMIT={{ toolshed_frontend_checkout.after[:7] }}
-f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend
changed_when: true
delegate_to: localhost
become: false
- name: Create local frontend dist scratch directory (controller)
ansible.builtin.file:
path: "{{ toolshed_frontend_build_dist_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
- name: Run frontend builder once to export the static build (controller)
ansible.builtin.command:
cmd: docker run --rm -v {{ toolshed_frontend_build_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
changed_when: true
delegate_to: localhost
become: false
- name: Sync built frontend dist to the target host
ansible.posix.synchronize:
src: "{{ toolshed_frontend_build_dist_dir }}/"
dest: "{{ toolshed_dist_dir }}/"
delete: true
delegate_to: localhost
become: false
- name: Fix ownership of exported frontend build
ansible.builtin.file:
path: "{{ toolshed_dist_dir }}"
owner: www-data
group: www-data
recurse: true
- name: Create wiki static output directory
ansible.builtin.file:
path: "{{ toolshed_wiki_dist_dir }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Build wiki builder docker image
ansible.builtin.command:
cmd: >-
docker build -t {{ toolshed_wiki_image }}:{{ toolshed_image_tag }}
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.wiki {{ toolshed_src_dir }}
changed_when: true
- name: Run wiki builder once to export the static site
ansible.builtin.command:
cmd: docker run --rm -v {{ toolshed_wiki_dist_dir }}:/output {{ toolshed_wiki_image }}:{{ toolshed_image_tag }}
changed_when: true
- name: Fix ownership of exported wiki build
ansible.builtin.file:
path: "{{ toolshed_wiki_dist_dir }}"
owner: www-data
group: www-data
recurse: true
- name: Create local fixtures directory
ansible.builtin.file:
path: "{{ toolshed_local_dir }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Write registration domain list fixture
ansible.builtin.copy:
dest: "{{ toolshed_local_dir }}/domains.json"
owner: www-data
group: www-data
mode: "0644"
content: "{{ toolshed_register_domains | to_nice_json }}"
- name: Write DoH resolver fixture
ansible.builtin.copy:
dest: "{{ toolshed_local_dir }}/dns.json"
owner: www-data
group: www-data
mode: "0644"
content: "{{ toolshed_doh_resolvers | to_nice_json }}"
- name: Create ACME HTTP-01 challenge webroot
ansible.builtin.file:
path: "{{ toolshed_letsencrypt_webroot }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
when: not (behind_tls_proxy | default(false) | bool)
- name: Check for an existing Let's Encrypt certificate
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
register: toolshed_cert
when: not (behind_tls_proxy | default(false) | bool)
- name: Configure nginx site for toolshed (bootstrap)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/toolshed.conf
owner: root
group: root
mode: "0644"
content: "{{ toolshed_nginx_conf }}"
notify: reload nginx
- name: Remove default nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: Enable toolshed nginx site
ansible.builtin.file:
src: /etc/nginx/sites-available/toolshed.conf
dest: /etc/nginx/sites-enabled/toolshed.conf
state: link
notify: reload nginx
# Certbot's webroot check (below) needs nginx already serving the
# bootstrap config from the tasks above, so force the reload now
# instead of waiting for the end of the play.
- name: Apply the bootstrap nginx config now
ansible.builtin.meta: flush_handlers
- name: Ensure the certbot renewal deploy-hook directory exists
ansible.builtin.file:
path: /etc/letsencrypt/renewal-hooks/deploy
state: directory
mode: "0755"
when: not (behind_tls_proxy | default(false) | bool)
- name: Reload nginx after certbot renews a certificate
ansible.builtin.copy:
dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
systemctl reload nginx
when: not (behind_tls_proxy | default(false) | bool)
- name: Obtain or renew the Let's Encrypt certificate
ansible.builtin.command:
cmd: >-
certbot certonly --webroot -w {{ toolshed_letsencrypt_webroot }}
-d {{ toolshed_hostnames | join(' -d ') }}
--non-interactive --agree-tos
-m {{ toolshed_letsencrypt_email | mandatory('toolshed_letsencrypt_email must be set as a host_var for ' ~ inventory_hostname ~ ' since behind_tls_proxy is false there') }}
register: toolshed_certbot
changed_when: "'Certificate not yet due for renewal' not in toolshed_certbot.stdout"
when: not (behind_tls_proxy | default(false) | bool)
- name: Re-check the certificate now that certbot has run
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
register: toolshed_cert
when: not (behind_tls_proxy | default(false) | bool)
- name: Configure nginx site for toolshed (final)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/toolshed.conf
owner: root
group: root
mode: "0644"
content: "{{ toolshed_nginx_conf }}"
notify: reload nginx
handlers:
- name: validate nginx config
ansible.builtin.command: nginx -t
listen: reload nginx
changed_when: false
- name: reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
listen: reload nginx
- name: restart backend
ansible.builtin.systemd:
name: toolshed-backend
daemon_reload: true
state: restarted
listen: restart backend

View file

@ -0,0 +1,113 @@
# Groups MVP (Design in Progress)
Status: not implemented. [groups.md](groups.md) works out the underlying model (what a group is,
how "acting as a group" is authenticated across federation). This document doesn't re-derive any
of that; it takes that design as given and asks a narrower question: what is the smallest set of
screens and actions that makes groups usable, reusing the UI patterns Toolshed already has for
friends and inventory rather than inventing new ones.
## Goals
- Ship something a user can actually click through: create a group, add/remove members, and have
the group own items, end to end.
- Reuse existing screens and interaction patterns wherever the shape already matches, instead of
designing new ones (see below).
- Leave every deferred piece as an explicit non-goal rather than a silent gap, so it's clear what
MVP does and doesn't cover.
## Non-goals (for now)
- **Group-to-group friending, and letting outside users friend a group.** groups.md's model
supports both, but the only audience for a group's items in MVP is the group's own members;
"private" already covers that (see "Availability policy for group items" below). Extending
visibility to non-members via the group's own friends list is real, valuable, and deferred as a
fast-follow, not designed here.
- **Transferring an existing personally-owned item's ownership to a group.** MVP only supports
creating a *new* item directly owned by a group. Moving an already-existing item across owners is
a separate piece of work (ownership transfer isn't something Toolshed supports for user-to-user
either today).
- **Deleting a group.** MVP has no explicit "delete this group" action. A group's lifecycle is just
"created, membership changes over time"; the orphaning guard (a group can't be left with zero
members, see groups.md's Known Gaps) means a group that's no longer wanted just sits unused rather
than needing a teardown flow.
- **Any governance, roles, or per-item permissions.** Same non-goal as groups.md: every member has
equal, full privileges over the group and everything it owns.
## User-facing features
### Creating a group
A new "Groups" section, entry point styled like Inventory's "Add" button. The form is just a name;
the handle (`#name@yourdomain`) is derived from it the same way a username becomes part of a user's
handle. The creator becomes the group's first member automatically — there's no empty-group state
to design for.
### My Groups list
A "Groups" nav entry/page, modeled directly on `Friends.vue`: a table of groups the current user is
a member of (name/handle, member count), each row linking into that group's detail page. No
separate "discover groups you're not in" browsing for MVP — you land in a group by being added to
it, the same way you become friends with someone by request/accept, not by browsing a directory of
all users.
Known limitation: this list only ever queries the member's own home backend, so it only shows
groups actually hosted there (groups you created, or joined on your own domain). Membership itself
works regardless of which backend hosts the group — a remote member can still be invited, accept,
and fully edit/delete the group's items (see "Owning items as a group" below) — but a group hosted
on someone else's backend won't show up in your own "My Groups" list, because unlike friendship
(which both sides record), group membership is only ever recorded on the group's own home backend,
and there's no index anywhere of "which other backends has this identity been added to." Making a
remote membership discoverable would need a small personal pointer index (written by the client at
join time) plus a handle-based group lookup on the group's own backend; deferred as a fast-follow
alongside group-friending.
### Group detail page
One page per group, with two sections, each reusing an existing pattern wholesale:
- **Members** — the add/remove-row pattern from `Friends.vue`'s friend list: an inline "add member"
field (enter a handle, `user@domain`), and a remove (trash icon) action per row. Any member can
add or remove any other member — flat privilege, no confirmation step beyond the orphaning guard
(removing the group's last member is blocked, with an error explaining why, rather than silently
emptying the group).
- **Group inventory** — the exact table/grid pattern from `Inventory.vue` (Name, Availability
Policy, Amount, Edit/Delete), scoped to items owned by this group, with the same "Add" button
leading into the existing item-creation form (see below).
A "Leave group" action removes the current user from Members; if they're the last member, it's
blocked by the same orphaning guard.
### Owning items as a group
The existing item create/edit form (`InventoryNew.vue` / `InventoryEdit.vue`) gets one new field:
an "Owner" selector, defaulting to "Myself," with the groups you belong to as the other options.
Every other field on that form (tags, properties, availability policy, storage location, quantity)
is unchanged, and a group-owned item behaves exactly like a personally-owned one everywhere else in
the app (edit, delete, detail view) — any member can edit or delete it, the same way the owner can
today.
### Where group-owned items show up
The main "Inventory" page stays scoped to items you personally own, unchanged from today. A group's
items are visible and managed in exactly one place: that group's detail page. This keeps "my
inventory" meaning one thing (what I personally own) and avoids merging two different item lists
with different edit semantics into one view for MVP.
### Availability policy for group items
The item form's existing Availability Policy field (private/share/lend/rent/sell) is unchanged and
applies to group-owned items the same way it does today. Since group-friending is out of scope for
MVP (see Non-goals), "share/lend/rent/sell" have no wider audience to expand to yet, only "private"
is fully meaningful right now — a private group item is visible to and editable by every group
member, which is already the core value the Problem statement in groups.md is after (a shared
workshop's members all having a say over shared equipment). The field stays as-is rather than being
trimmed down to just "private," so nothing needs to change on it once group-friending ships.
## Open scoping call
Deferring group-friending is the one judgment call in this document worth flagging explicitly:
it means an MVP group can't yet share an item with anyone outside its own membership, which is a
real limitation, not just a simplification. It was scoped out because it pulls in a second piece of
UI (a group's own Friends tab, and "who can accept a friend request on the group's behalf") that
isn't needed for the core "shared ownership among members" use case to work end to end. Worth
confirming this is the right line before building against it.

View file

@ -0,0 +1,233 @@
# Groups (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
adding groups to Toolshed. Nothing here is settled; it's a starting point for discussion.
## What a group is
A group exists to model shared ownership of items, the way a club, workshop, or company owns
equipment collectively rather than any one person owning it. A group has members, and all members
have equal privileges to edit the items the group owns; there's no owner-vs-member distinction
within a group, membership itself is the privilege.
This is a sharper (and more consequential) definition than "a label you can share things with":
it means a group needs to be able to *own* things, not just receive shared access to them the way
a friend does. That has implications worked out below.
## Problem
The only relationship Toolshed currently models between users is pairwise friendship (see
[federation.md](../federation.md)), and every item has exactly one owning user. That's enough for
"I trust this one specific person" and "I personally own this thing," but it breaks down for
anything collectively owned:
- A shared workshop, tool library, or team can't own equipment as a unit. Today it has to belong
to one specific person's account, which is a poor fit and doesn't reflect who actually has a say
over it.
- There's no way for several equally-privileged people to edit the same item; edit rights today
are entirely tied to the single `owner` field.
- Adding or removing a member of an informal group currently means renegotiating friendships and
re-sharing individually; there's no shared object whose membership can just be edited once.
## Goals
- Let a set of users collectively own items, with every member holding equal edit rights over
those items.
- Let membership be managed in one place instead of via N pairwise arrangements.
- A request from a client to any backend may not depend on any other backend being online at the
same time — not to construct the request, and not to verify it. Concretely: the receiving server
must be able to verify the request using only the request itself plus keys it has already cached
from prior trust (friend-accept), with the group's own authoritative backend and the requesting
member's home server both unneeded and unreachable-safe at verification time; and the client must
be able to send the request using only what it already has cached, with the group's authoritative
backend unneeded and unreachable-safe at send time too. This is the same property plain user
requests already have (see federation.md's Cryptography section); group requests must not regress
it on either side.
- Fit into the existing handle system (see federation.md's "Unique Handles" section): a group
should be nameable and referenceable using the same handle shape a user is.
- Stay optional and additive. Pairwise friendship and single-user ownership should keep working
exactly as they do now for people who never touch groups.
## Non-goals (for now)
- Group governance beyond flat, equal membership (voting, roles, hierarchies). Equal privileges
for all members is the whole model for now; anything more layers on top later if needed.
- Fine-grained per-item permissions within a group (e.g. "this member can edit but not delete").
Membership is the only privilege level.
## Open design questions
### Does a group need its own keypair?
No. A group's day-to-day existence is a membership roster maintained by whichever backend is
authoritative for the group's handle (the same "authoritative backend" idea a user's domain already
implies, see federation.md's Servers subsection). A request made "as the group" is an ordinary
request, signed with an actual member's own personal private key, together with a claim of which
group it's acting on behalf of (`acting_as`), signed as part of the same payload as the rest of the
request.
A receiving server must be able to authenticate such a request using only what arrives in the
request plus keys it already holds; no other server, including the group's own authoritative
backend, needs to be reachable at verification time. This is accomplished with **membership
certificates**: on request, a group's authoritative backend issues a current member a small
signed statement of the shape "handle X, public key P, is a member of group #G, valid from T1 until
T2," signed with the group's own private key. Issuance is on-demand (the member asks, rather than
the backend pushing renewals on a schedule), but it is its own separate action, decoupled from
sending any particular group request: a member fetches and refreshes this certificate from the
group's backend whenever they happen to be online, caches it locally, and later attaches whichever
certificate they currently hold to a request they make "as the group." Sending that request never
itself triggers a live fetch from the group's backend — if the cached certificate has expired and
the group's backend isn't reachable right then, the request simply can't be sent as the group yet;
the client doesn't fall back to contacting the group's backend synchronously to get one.
A receiving server checks two signatures, using keys it already has cached, with no outgoing call
to anyone: the member's signature over the request, using the public key embedded in the
certificate itself, and the certificate's own signature, using the group's public key, learned and
cached exactly the way any friend's key is, at the point the group was friended. If both check out
and the certificate hasn't expired, the request is authorized.
The certificate is what lets a receiving server trust a specific member's public key at all, for
members it has never individually friended: that trust is vouched for by the group's already-cached
key, rather than requiring a separate key-exchange with every member of every group a user happens
to be friends with. Equal privileges for all members falls out of this directly, since any member's
own key plus a valid certificate is sufficient proof.
Removing a member takes effect once their existing certificate expires, not the moment the
backend's roster is edited; certificate lifetime is the parameter that governs how quickly a
removal actually takes hold (see "How long should a membership certificate be valid for?" below,
and Security below).
### Are group handles different from user handles, or is a group just a special kind of user?
The handle should look almost exactly like a user handle, just prefixed with `#`: `#groupname@domain`
instead of `groupname@domain`. It's resolved the same way and referenceable in the same places (e.g.
as an item's owner, or as a friend-list entry), so the reuse of the existing federation model is
unaffected. The prefix exists only to keep group and user handles from occupying the same namespace on
a domain: without it, "is `groupname@domain` a user or a group" would depend on which one happened to
register the name first, and the two could never be told apart just by looking at the handle. With the
prefix, a domain can have both a `climbing@domain` user and a `#climbing@domain` group with no
collision and no ambiguity about which is which, and any code path that resolves a handle can dispatch
on the actor kind (user vs. group) from the handle's own shape, before it even needs to ask a server.
But underneath, a group isn't really "a special kind of user," it's backed by a fundamentally
different kind of identity: a membership list instead of a single keypair (see above). A user
handle answers "is this request authentically from this one identity," a group handle answers "is
this request from someone currently entitled to act for this collective identity." Those are
different questions even though the answer to both ends up being "yes, forward the request." Groups
and users are probably best thought of as two kinds of actor that share a handle format and most of
the surrounding plumbing (ownership, friendship, availability policy), rather than one being a
special case of the other.
### Should a group be able to grant read access to non-members ("group friends")?
Yes, this should reuse the same mechanism a user's own sharing already uses. Since a group is an
actor with a handle, it can have its own friends the same way a user does, and a group's owned
items can go through the same availability-policy check (private vs. visible to the group's
friends) that an individual's items already do. Nothing new needs to be invented here, it's the
existing friendship and availability-policy machinery applied to a second kind of actor.
### Should groups be able to befriend other groups?
Yes, for the same reason: if a group is an actor with a handle and a friends list, there's no
reason the other side of that friendship has to be a user specifically. Two clubs befriending each
other so each can see the other's shared equipment is the same mechanism as two users befriending
each other, just with both sides being groups instead of one or zero.
### Should anyone be able to share directly with a specific group, instead of with "my friends" generally?
This is the one piece that isn't just reuse of what exists today. Right now, an item's
availability policy is all-or-nothing across *all* of the owner's friends, there's no way to share
with a subset of friends, individually or as a named group. Letting an item be shared with one
specific group (a user's own item, shared with a club they belong to, say) instead of with every
friend equally would be a genuine generalization of the current sharing model, not something that
falls out of adding groups as an actor.
Worth noting: this capability would be just as useful for individual users wanting to share with a
subset of their friends, without a group being involved at all. It might make more sense to design
"share with a specific target (user, group, or named subset)" as its own piece of work, rather than
building it as a groups-only feature.
### How long should a membership certificate be valid for?
This is the tuning knob the certificate design introduces, and it's a real trade-off rather than a
detail to defer. A short validity window (say, hours) keeps the staleness window after a removal
small, but means a member who's offline for longer than that can't act as the group at all until
they reconnect and refresh. A long window (days or weeks) is more forgiving of intermittent
connectivity but leaves a removed member's old certificate usable for longer. Whatever default is
picked, a member should be able to fetch a fresh certificate well before the old one expires while
still online, so the common case isn't "offline for exactly the wrong amount of time."
## Interaction with availability policy
Items currently have an availability policy (private / share / lend / rent / sell) that's a
property of the item, not a list of who it applies to, "friends" is implicit and applies equally to
all of them. A group-owned item works the same way, just with the group's own friends as the
implicit audience instead of an individual's. Targeted sharing (the question above) would extend
this, not replace it.
## Known gaps in the design
None of these block the design, but they're real gaps that need an explicit answer before
implementation.
### Ambiguity
- **What exactly is signed.** The `acting_as` claim and the certificate's identifying fields must be
signed as part of the same payload the member's key signs, not as free-standing, unsigned data
alongside it. If they aren't inside the signed bytes, `acting_as` can be swapped after signing,
turning a personal request into a group one or vice versa, or one group's request into another's.
- **Who can change membership, and how.** Equal privileges to edit items doesn't by itself say
whether that equality extends to *requesting or renewing certificates for others, or editing the
authoritative backend's issuance list itself*. Flat and unilateral (any member can add or remove
any member) is the simplest reading of "no owner-vs-member distinction," but it's a materially
different trust model from "equal edit rights over items" and deserves its own explicit decision.
### Security
- **Confused deputy on `acting_as`.** Trust in an `acting_as` claim reduces to trust in the group's
authoritative backend's issuance decisions: a malicious or compromised backend can sign a
certificate for a handle that was never really a member, and every receiving server that trusts
the group's key will accept it. The backend's issuance discipline, and the security of its own
private key, is a single point of failure for the group as a whole.
- **Membership staleness window.** There is a window after a member is removed during which their
existing certificate keeps working: exactly the certificate's remaining validity period. This is a
strictly worse revocation story than individual friendship, where trust is keyed to a public key
learned once with no expiry, but the window is a bounded, chosen parameter (see "How long should a
membership certificate be valid for?" above) rather than open-ended.
- **Blast radius of a single compromised member key.** Because membership is flat and unilaterally
editable by any member, a compromised personal key doesn't just expose that person's own items,
as with an ordinary account compromise, it exposes edit rights over everything the group owns for
as long as that member's certificate remains valid, and can be used to obtain a certificate for an
attacker-controlled handle as a permanent member before anyone notices. This risk is inherent to
"equal privileges, no roles" as a model, worth flagging even though richer governance is a
non-goal for now.
- **Self-lockout / orphaning.** Nothing in the design stops a group's last member from leaving (or
removing everyone else) from the backend's issuance list, which would leave group-owned items with
no one able to obtain a valid certificate for that owner at all once existing certificates expire.
The backend should guard against removing the last member, but that guard doesn't address a
member unilaterally removing every *other* member, which the flat model otherwise permits.
- **Audit trail depends on discipline.** Since any member's signature plus a valid certificate
satisfies authorization, "the group edited this item" is never sufficient for an audit trail; the
actual signer's handle (from the certificate's embedded public key) must always be logged
alongside the group claim, or member-level accountability is lost entirely.
### Maintainability
- **Two actor kinds sharing one code path.** `Group` should have the same shape as `User` for the
things that matter (a `.friends` set, a `.handle`), so ownership/friending/availability-policy
code can stay actor-agnostic. That reuse only holds if future code is disciplined about not
special-casing `User` in ways that assume a single, non-expiring keypair (e.g. "cache the owner's
public key forever, no expiry check needed") — a shortcut that would silently break the moment
the owner turns out to be a group, where the *acting member's* key is only good until its
certificate expires.
- **Certificate issuance and refresh is a client responsibility.** A member's client needs to
refresh its certificate before it expires, handle in-flight group actions failing closed if it
didn't (the same as any expired-credential error), and surface refresh failures to the user
rather than as a confusing rejected request.
- **Expanded federation test surface.** Every existing federation test implicitly assumes the
request's signer and its authorized actor are the same handle. `acting_as` plus an embedded
membership certificate means the whole request-verification path needs testing for the
signer-vs-actor split and the certificate's own signature and expiry checks, including
cross-domain cases (group hosted on one domain, member's key registered on another, item owned by
the group sitting on a third) and expiry-boundary cases (certificate expires mid-flight, is
refreshed concurrently with a request, etc).

View file

@ -0,0 +1,166 @@
# Authenticated image caching (Design in Progress)
Status: not implemented. This document proposes a fix for a real performance gap: every
authenticated image in the app is refetched, re-verified, and re-decoded from scratch on every page
load, even though the backend already sends headers built for exactly the opposite.
## Problem
Images are served from `GET /media/<hash_path>` and `GET /media/<size>/<hash_path>/`
(`backend/files/media_urls.py`), both gated behind `SignatureAuthentication`
(`backend/authentication/signature_auth.py`): the client signs the full request URL with an Ed25519
key and sends `Authorization: Signature <user>@<domain>:<sig>`. There's no cookie and no
URL-embedded token — auth lives entirely in a request header that a browser has no way to attach to
a plain `<img src="...">`. So `AuthenticatedImage.vue` does it by hand: `fetch()` with the header,
`.blob()`, `URL.createObjectURL()`, assign that to `src` (`federation.js`'s `getRaw`,
`fileCache.js`). `fileCache.js` is a module-level `Map` — it dedupes concurrent requests and holds
decoded blobs for the life of the page, but it's memory-only. Reload the page (or just navigate
between the SPA's route-based chunks in a way that re-mounts things) and it's gone; every image the
user has already looked at gets fetched, signature-verified, and blob-decoded all over again.
Meanwhile the backend response already carries `ETag`, `Cache-Control: max-age=31536000, private,
immutable`, and a 365-day `Expires` (`_cache_headers`, `media_urls.py`) — because `src` is a
SHA-256 hash-addressed path, the same URL can only ever mean the same bytes, forever. Those headers
are correct and unused: nothing durable in the client ever consults them. This design closes that
gap using the browser's own Cache Storage API, without touching the backend.
## Goals
- Make a previously-viewed image load instantly on the next page load / browser restart, not just
within the current tab's JS session.
- Do it without weakening the authorization model: a signature is still required and verified
server-side for the *first* fetch of a given file by a given identity. Caching must not let one
identity's cached bytes leak to a different identity sharing the same browser.
- Reuse the backend's existing headers rather than inventing a parallel freshness scheme — content
is immutable, so a cache hit needs zero revalidation, ever.
- No backend changes. This is purely a client-side storage question.
## Non-goals (for now)
- **Revoking already-cached bytes when access changes** (e.g. an unfriend). The backend's own
1-year `Cache-Control` already accepts that risk today for anything an HTTP-compliant cache might
hold; a persistent client cache extends the shelf life of that same accepted risk, it doesn't
introduce a new one. Not solving revocation here.
- **Prefetching / warming the cache ahead of navigation.** Real optimization, separate piece of
work; this document is about not throwing away work already done.
- **A Service Worker that reinstates plain `<img src>`.** Sketched below as a follow-up because it's
the "real" fix for the root cause (no way to attach a header to an `<img>` request), but it's a
bigger lift (SW lifecycle, an extra message-passing bridge for signing) than the storage win alone
needs. Scoped out of this pass.
## Design: persist `fileCache` with the Cache Storage API
`window.caches` (the `CacheStorage` interface) is available to any page context, not just inside a
Service Worker — `caches.open(name)` gives a store of real `Request`/`Response` pairs that survives
reloads and browser restarts, backed by the browser's own disk quota. That's the missing tier;
nothing else about `fileCache.js`'s existing shape needs to change.
**Two tiers, not one:**
- **L1 — in-memory `Map<key, objectURL>`** (what exists today). Kept as-is: within a single page
session, components just want the already-created object URL back without re-touching storage at
all. Same LRU/budget logic (`MAX_BYTES`), unchanged.
- **L2 — `CacheStorage`**, consulted on an L1 miss, before falling back to the network. Holds raw
`Response` objects (not blobs), keyed by the same request used for the authenticated fetch.
Revised `get(key, fetcher)` flow:
1. L1 hit → return the object URL, as today.
2. L1 miss → check `cache.match(request)`. Hit → `.blob()` the cached response, create the object
URL, populate L1, done. **No conditional GET, no revalidation** — the response is `immutable`,
so if it's in the cache it's still correct by construction.
3. L2 miss → run the existing authenticated `getRaw()` fetch. On success, `cache.put(request,
response.clone())` before consuming the body, then proceed as today (`.blob()`, object URL,
populate L1).
**Namespacing by identity, not one global cache.** `Cache-Control: private` on the response is the
backend telling shared caches to stay out — correct, since access is per-requester
(`_accessible_files`'s friends-or-self check). A single browser-wide `CacheStorage` bucket keyed
only by URL would quietly turn into exactly the shared cache that header is warning off, *if* this
browser ever holds more than one local identity (switching accounts, a shared machine). Concretely:
open the cache as `images-${username}@${domain}` (derived from the active `state.keypair`, the same
identity that produces the signature) rather than a single `"images"` name. Same-identity re-fetches
get the full cache benefit; a different identity in the same browser starts with an empty bucket and
goes through the normal authenticated-fetch-then-verify path, same as it does today. `invalidate()`
and `clear()` already exist on `FileCache` but nothing calls them — wire `clear()` to also
`caches.delete(currentNamespace)` and call it on logout/identity-switch, which is the natural,
already-there hook for this.
**Storage budget.** L2 doesn't need its own hard byte cap the way L1 does — `CacheStorage` is
subject to the browser's own storage-pressure eviction, which is the right backstop for "durable but
not sacred" data like this. Optionally call `navigator.storage.persist()` once at startup to ask the
browser to exempt the origin from casual eviction under pressure; harmless to skip if declined.
**Net effect:** a returning user's already-seen images (inventory thumbnails, profile pictures,
friends' shared items) render from disk with zero network round-trips and zero re-verification,
using exactly the durability guarantee (`immutable`, hash-addressed) the backend already asserts.
First-time images are unaffected — same authenticated fetch as today, just now also written to L2 on
the way through.
## Follow-up worth flagging: a Service Worker to restore plain `<img>`
The deeper cost isn't just the network round-trip — it's that every image, cached or not, is forced
through manual `fetch → blob → createObjectURL`, so the browser's native image pipeline (off-main
thread decode, `loading="lazy"`, `fetchpriority`, responsive `srcset`) is unavailable, and object
URLs have to be manually revoked (`fileCache.js` already does this correctly, but every new call
site is a chance to leak one). The reason the app can't use plain `<img src>` at all is that nothing
can attach the `Authorization: Signature` header to a browser-initiated image request.
A Service Worker can, because its `fetch` handler intercepts requests — including image loads —
before they leave the page, and can substitute its own request in place of the original:
- On a `fetch` event where `event.request.destination === 'image'` and the URL matches `/media/`,
check the (identity-namespaced) `CacheStorage` first; hit → respond straight from cache, no
network at all.
- Miss → the SW doesn't have the signing key (it lives in page memory / `localStorage`, neither
reachable from a SW), so it asks the one controlled client (`self.clients.get(event.clientId)`
the specific tab that issued the request, not "any open tab") for a signature over this exact URL
via `postMessage`/`MessageChannel` — an in-process round trip, not a network call — attaches the
returned header, performs the real fetch, stores the result in `CacheStorage`, and responds with
it.
- Once this exists, `AuthenticatedImage.vue` can go back to `<img :src="mediaUrl" loading="lazy"
decoding="async">` directly; the SW is what makes that legal despite the custom auth scheme.
### Scoping the signing bridge: a compromised SW must not become a "sign anything" oracle
The message bridge above is the one new capability this design adds that doesn't exist today: a
channel through which something can ask the page to sign a URL on its behalf. A Service Worker is a
long-lived, network-interposing piece of code — exactly the kind of thing a supply-chain compromise
or an XSS-planted `registration.update()` would target. If the page's message handler blindly signs
whatever URL the request names, a compromised SW stops being "something that can read images this
identity can already see" and becomes "something that can get a validly-signed request for *any*
endpoint" — e.g. `POST /api/inventory/items/5/delete` or `POST /api/friends/accept` — and then just
replay it directly against the real backend. That's a full account-takeover primitive smuggled in
through what was supposed to be an image-caching optimization, and it's strictly worse than not
having the bridge at all.
The fix has to live on the page side of the channel, since the SW is the presumed-compromised
component in this threat model and can't be trusted to police itself. Treat the message handler as a
dedicated, narrow function — not a thin wrapper around the app's general-purpose signer
(`createSignAuth` in `federation.js`, which is used for arbitrary API calls elsewhere in the app) —
that:
- **Ignores any method the request claims and always signs as `GET`.** The bridge never accepts a
body/`data` field from the SW at all, which closes off the entire class of mutating requests
(`POST`/`PUT`/`PATCH`) regardless of what path is named.
- **Validates the path against a strict allowlist grammar before signing anything**, rather than a
loose "starts with `/media/`" check. `src` values are hash-addressed —
`/media/<hex>/<hex>/<64-hex-char-sha256>.<ext>` for originals, with an optional `/<32|64|256>/`
size prefix for thumbnails. Because the variable part is constrained to `[0-9a-f]`, a regex over
that exact shape is effectively a closed grammar: `.` and `/` (the characters path traversal or
extra-segment tricks would need) simply aren't in the hex alphabet, so there's no meaningfully
malformed input that still matches. Anything that doesn't match — a different endpoint, an
encoded traversal attempt, an extra query string — is refused, silently or with a logged warning,
never signed.
- Optionally also checks the URL's host against the identity's home domain or its current friend
servers (belt-and-suspenders — a signature is bound to the exact signed URL string, so it can't be
replayed against a different host than the one named in it, but this catches a compromised SW
fishing for signatures against a host that happens to also trust this key for unrelated reasons).
With this in place, the worst a fully compromised SW can do is obtain signed `GET`s for images the
current identity is already authorized to fetch — the same blast radius as "can read the
already-authorized image cache" — not an oracle for arbitrary authenticated mutation.
Deferred because it adds real surface area (SW registration/update lifecycle, this scoped
message-passing bridge, first-load-before-SW-is-active edge cases) beyond what the storage change
alone needs. Worth doing as a second pass once the simpler win above is in and paying off.

View file

@ -0,0 +1,131 @@
# Item Handles & Physical Labels (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
giving inventory items stable identifiers and physical (scannable) labels. Nothing here is
settled.
## Problem
As described in federation.md's "Unique Handles" section, an item today is identified only by a
local id scoped to its owner, it isn't given an explicit, portable handle the way a tag, property,
or category is. That's fine as long as the only thing ever addressing an item is the owning user's
own signed API traffic. It stops being fine the moment something *outside* that loop needs to
refer to the item:
- A friend who borrowed a physical tool has no way to look it up other than finding it in the
owner's shared inventory list by eye.
There's nothing you could put on a sticker.
- If items ever need to be referenced from outside their owner's own requests (a group's shared
view, a lending record, a printed label), there's currently no stable identifier to reference
that's meaningful outside the owner's own account.
- A local database id isn't something we'd want to expose or rely on externally: it's an
implementation detail of one backend's storage, not a handle with the same guarantees
(uniqueness, meaning, longevity) the rest of the federation model gives every other kind of
entity.
Put simply: every other kind of thing in Toolshed (users, tags, properties, categories) has a
handle that means something outside of one database. Items don't, and physical labeling is the
clearest case where that gap actually matters.
## Goals
- Give an item a handle that's meaningful and resolvable outside its owner's own account, without
requiring items to become shared/reusable entities the way tags are (an item is still owned by
exactly one person; see the "Items" subsection of federation.md for why that keeps things
simple).
- Support a physical label (QR code, barcode, or similar) that can be printed and stuck on a real
object, such that scanning it gets you to the right item on the right backend.
- Make the label survive the normal life of a physical object: it gets lent out, comes back,
maybe changes which storage location it lives in, all without needing a new label printed.
## Non-goals (for now)
- Turning items into shareable/reusable entities across owners (that's what tags/categories are
for; see [tags.md](tags.md)). An item handle identifies *this specific person's specific thing*,
not a class of thing.
- Solving inventory tracking/auditing (check-in/check-out logs) as a whole system; that can build
on top of a stable item handle once one exists, but isn't the same problem.
## Open design questions
**What does the handle look like?**
The natural extension of the existing scheme is owner handle + local id, that's enough to be
globally unique (no two users share a handle, and ids are already unique within one user's
inventory) without inventing a new namespace. Worth deciding whether the id should be the existing
internal database id (simple, but leaks a little implementation detail and a rough count of
someone's inventory) or a separate opaque id generated for exactly this purpose (see the
unguessability question below).
Two distinct formats are needed, because "an item handle" is used in two different situations:
- *A compact handle, for use where context already makes clear it's a Toolshed item.* Inside the
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data,
the handle doesn't need to spell that out or be openable on its own. This can be as short as
`user@domain.tld:id`, the item's owner handle with `:id` appended, mirroring how a tag/category
handle already appends `:name` after its origin (see federation.md's Unique Handles section).
No new delimiter concept, just the same pattern applied to items.
- *A self-contained URL, for use with no context at all.* A physical label, a link shared outside
the app, has to work without the reader already knowing what it is or which server it belongs
to, so it needs to open directly to the right frontend, resolve the right backend, and land on
the right item. That means it has to encode the same information (owner handle + item id) as a
full URL, e.g. `https://toolshed.webdomain.tld/i/alice@example.com/42`, note the owner's handle
can be embedded in a path segment as-is (`@` doesn't need escaping in a URL path), which keeps it
one segment shorter than splitting the handle back into `domain/user`, and means the handle is
visible unmodified inside the link rather than reassembled from separate parts. If the owner is
ever a group rather than a user (see groups.md), its handle carries a leading `#`, which does need
the `+`-for-`#` substitution described in [handles-and-shortids.md](../handles-and-shortids.md)'s
Handle syntax section before it can sit in a path segment, e.g.
`https://toolshed.webdomain.tld/i/+climbing@example.com/42`. The frontend host
in this URL (`toolshed.webdomain.tld`) doesn't have to be, and generally won't be, the backend
authoritative for `example.com`, any frontend can resolve any handle (see federation.md's Servers
subsection), so this is just whichever frontend happens to be handling the link, not part of the
item's identity.
Both formats should stay as short as the encoded information allows, this matters most for the URL
form, since it's the one that ends up in a QR code or printed label where physical size is a real
constraint (see the labels goal above).
**How does this fit with the frontend's existing routes?**
There's already a `/inventory/shared/:user/:id` route (`InventoryDetailForeign`), but today
`:user` is just a bare username with no domain, i.e. it only works for a friend on the viewer's own
domain, and the view itself doesn't yet do anything domain-aware with that param. A resolvable
global handle needs the full `user@domain.tld` and a lookup step this route doesn't have yet. Two
ways to reconcile that: extend the existing route to take a full handle in the `:user` segment
(`/inventory/shared/alice@example.com/42`, no new route shape needed, just a richer meaning for the
param it already has), or treat the short `/i/...` URL as a dedicated, minimal entry point whose
only job is to resolve a handle and then hand off into whatever the richer in-app view ends up
being. The two aren't mutually exclusive: the short form is what needs to be small enough to print,
the in-app route doesn't have the same constraint and can stay more descriptive.
**What does scanning a label actually do?**
Probably: the label encodes a URL or handle-like string; scanning it opens the frontend, which
resolves the owner's domain the same way it resolves any other handle (see federation.md), and
lands on that item. This reuses the discovery mechanism that already exists for logging in as a
handle, rather than inventing a second one.
**Does resolving a label require authorization?**
An item's availability policy already controls who can see it (owner-only if private, friends if
shared, etc.). A label should presumably respect the same policy rather than being a backdoor that
makes a private item visible to literally anyone who finds the physical object and scans its code.
That means resolving a label isn't a free public lookup, it goes through the same friend/signature
checks as everything else, which has UX implications (an anonymous finder of a lost tool can't
necessarily see who it belongs to).
**Does the label need to be opaque/unguessable?**
If item ids are small sequential integers, a label built from a guessable id lets anyone enumerate
a user's items by scanning or guessing nearby numbers, even if each individual lookup is
authorization-checked. Probably wants some amount of unguessability even before authorization is
considered, as a defense-in-depth measure.
**What survives item changes?**
Storage location, availability policy, name, and description can all change over the life of an
object without it becoming a "different" item. The label should point at the handle, not at any of
that mutable data, so none of those changes require a new label. The one thing that probably *does*
need a decision is deletion: does a handle ever get reused, or is it retired for good once an item
is deleted (retiring seems safer, avoids an old label resolving to an unrelated new item later)?
**Relationship to lending/borrowing.**
A scannable label is the obvious hook for a future "mark as borrowed / returned" flow. Not solving
that now, but the handle scheme chosen here should be able to carry that later without a redesign,
i.e. it should be able to identify the item independent of who currently physically has it.

View file

@ -0,0 +1,252 @@
# Tags, Properties & Categories (Design in Progress)
Status: partially implemented (the handle scheme and strict resolution described in federation.md
exist today); this document is about the rough edges and open questions in that design, not a
proposal to build it from scratch.
## Problem
Tags, properties, and categories are handles of the form `origin#type:name` (see federation.md's
"Unique Handles" section), and a reference to a handle a server doesn't recognize is left
unresolved rather than guessed at or merged. That strictness is deliberate and avoids silent data
corruption, but it pushes a real cost onto users and creates some open problems:
- **Discoverability.** There's no way today to search or browse "what origins/taxonomies already
exist" before creating a new tag. In practice this likely means people invent their own local
tags for things a shared, canonical origin already covers, simply because they didn't know it
existed.
- **Fragmentation.** Because uniqueness is scoped per origin, nothing stops semantically identical
concepts from existing under different names or different origins at once (`drill` vs.
`power-drill`, one server's `origin:mytools` vs. another's `origin:community-tools`). Nothing
reconciles these; they just coexist.
- **No aliasing/synonyms.** If a user starts out with a local tag and later wants to adopt a shared
origin's equivalent, there's no supported way to merge or alias the two; existing items keep
pointing at the old handle.
- **Trust and quality of shared origins.** An origin is "whatever the classification is considered
to have come from," which could be an imported reference dataset. Nothing in the current design
addresses who maintains such a dataset, how it's kept correct, or what happens when it changes
(a category gets renamed or split upstream, e.g.).
- **Localization.** A tag/category name is a single string. There's no notion of the same concept
having a different display name per language while still resolving to the same handle.
- **Property values aren't typed.** A property's value on an item is a plain string. A property
definition can carry a unit (`unit_symbol`/`unit_name`), but nothing declares that its values are
actually numbers meant to be read in that unit, versus, say, free text that happens to mention a
unit. This is already a problem for one server in isolation (see the worked example below), and
federation doesn't create it, it just means it now has to be solved consistently across origins
instead of once.
- **Handle collisions.** `origin` is a free-text string with nothing enforcing that it's actually
unique to one definition. Fragmentation (above) is two different strings for the same thing;
this is the more dangerous mirror image, the same handle string ending up attached to two
different definitions, which is exactly the scenario the strict-resolution design was supposed
to make impossible (see the second worked example below).
### Worked example: filtering by a numeric range across origins
Say two independently-run servers both end up with a property called "voltage," each under its own
origin: `git:base#property:voltage` and `some-other-origin#property:voltage`. A user who's friends
with people on both wants to filter their combined view of shared items by, say, `0 < voltage <
4`. Two separate problems stack up:
1. **Are these the same property?** Per the fragmentation problem above, the two handles are, and
stay, unrelated as far as the system is concerned, same name, different origin, no connection.
A range filter built against one handle simply won't match items tagged with the other, even
though a person looking at both would probably call them "the same thing."
2. **Even if they were recognized as the same thing, are the values comparable?** A range filter
needs actual numbers in a known unit. If one server's items store `"3.7"` and the other's store
`"3700"` (volts vs. millivolts), a numeric comparison across the two silently produces nonsense
unless the unit is known and converted. If either side stores the value as loosely-formatted
text (`"3.7V"`, `"~3.7"`) rather than a bare number, it may not be reliably parseable as a
number at all.
So a cross-origin range filter needs both an aliasing/equivalence answer (are `voltage` and
`voltage` the same concept) and a units/typing answer (are their values actually numbers, in units
that convert cleanly into each other), and the first being solved doesn't imply the second is.
### Worked example: the same handle meaning two different things
Say `git:ee2` names a specific reference dataset that started life in one shared git history, and
two servers each imported it, at different times, from what has since become two diverging
branches (or forks) of that history. Both servers now have a property whose handle is the exact
same string, `git:ee2#property:charging_voltage`, but whose actual definition, say, unit, or
dimensions has since diverged between the two branches. Neither server did anything wrong; each
one faithfully imported "`git:ee2`" as it existed at the time.
This is a materially worse problem than the fragmentation/voltage example above. Fragmentation is
a missed opportunity, two things that should be linked aren't, and the failure is visible (the
filter just doesn't match as much as a person would expect). A handle collision is silent: nothing
about the two servers exchanging data suggests anything is wrong, both sides say
`git:ee2#property:charging_voltage`, so anything that trusts equal-handle-means-equal-definition
(exactly what the strict-resolution design promises, and exactly what a filter, an alias, or a
plain item-detail display would rely on) can silently combine or display incompatible values as if
they were the same thing. This is the specific failure mode the whole handle design exists to
prevent, so a scheme where it can still happen is a real gap, not just an inconvenience.
The root cause is that `origin` is a free-text label describing where something came from, not an
identifier that's actually bound to a specific, fixed piece of content. A name like `git:ee2` reads
as if it points to something immutable, but nothing about the origin field enforces that, `ee2`
could easily be a branch or tag name rather than a specific commit, i.e. a pointer that can keep
moving, and two importers pinned to it at different times without ever taking on a different
handle to show for it.
This isn't hypothetical, it's exactly how the reference data included in this repository already
works. The files in `backend/shared_data/` (`base.json`, `ee.json`, etc.) are origin datasets:
`configure.py`'s import step sets `origin = "git:" + filename` for everything a file defines
(`configure.py:119`), and a file can declare a fixed dependency on another one by name, e.g.
`ee.json`'s `"depends": ["git:base"]`. The intent is clearly that these files are immutable once
committed, and that a `depends` entry is a pin to a specific, settled parent, not a moving target,
but nothing in the code enforces that today, it's a convention people are expected to follow.
There's already a piece of the machinery needed to enforce it, though: import already computes a
sha256 of each file's raw content and stores it (`configure.py:125`, saved onto
`ImportedIdentifierSets.hash`, which is `unique=True` alongside `name`, `hostadmin/models.py:15`).
It's just not used for the thing it would be useful for, the "already imported, skipping" check
(`configure.py:129-130`) matches on `name` alone, it never recomputes the hash of the file being
imported and compares it against the hash already on record for that name. So even on a single
server, an edited `ee.json` re-imported under its old filename wouldn't be noticed as a change,
let alone flagged as a conflict, and the hash never leaves that server's own bookkeeping to be
compared against what a friend server has on record for the same name.
## Goals
- Make it easy to find and reuse an existing origin/handle before inventing a new local one, to
reduce fragmentation without weakening the strict-resolution guarantee that already exists.
- Give users a path to move a locally-invented tag onto a shared origin later, without losing or
having to manually re-tag their existing items.
- Keep the core guarantee intact: a handle always means one specific, traceable thing, nothing
should be implicitly merged or reinterpreted across origins.
- Make that guarantee actually hold, not just assumed: two servers that both use a given handle
should either really mean the same definition, or have some way to find out they don't, rather
than the collision staying silent.
## Non-goals (for now)
- Building a moderation/governance system for shared origins. Worth thinking about, but a bigger
problem than this document is trying to scope.
- Free-text/fuzzy tag matching in search. Search UX can layer on top of resolved handles without
changing what a handle means.
## Open design ideas
**A small set of well-known, shipped origins.**
Toolshed could ship with one or a few canonical origins covering common tool/inventory categories
out of the box, so that a fresh server already has a sensible baseline vocabulary to reuse instead
of every server reinventing "drill," "screwdriver," "power tools," etc. independently. Doesn't
solve fragmentation for everything, but raises the floor.
**Explicit aliasing rather than merging.**
Rather than trying to detect and merge "equivalent" tags automatically (risky, exactly the kind of
implicit behavior the strict-resolution design intentionally avoids), a tag could carry an explicit,
user-initiated "supersedes"/"alias of" pointer to another handle. Items already tagged with the old
handle could then be offered a one-time, explicit re-tag rather than a silent change of meaning.
For properties specifically, an alias needs to claim more than "these mean the same thing," it
needs to claim the values are comparable, which means recording a unit conversion (possibly just
"identical unit, factor 1") alongside the alias, not just a bare pointer. An alias with no stated
conversion should probably be treated as "same concept, values not (yet) comparable," a range
filter has no business guessing a conversion on its own.
**Filters default to per-handle, and only widen on an explicit alias.**
Following directly from the strict-resolution philosophy in federation.md: a range filter should
only ever combine two distinct property handles into one filterable facet because of an explicit
alias (see above) that also states the values are comparable, never because their names or units
happen to match. Absent that, two same-named properties from different origins should just show up
as two separate filters, visibly distinct, rather than the UI silently guessing they're the same
and producing a filter result that mixes incomparable values.
**Typed property values.**
Giving a property definition a declared value type (number, text, boolean, ...) in addition to its
existing unit metadata would let both the frontend and the alias/conversion mechanism above know
whether "range filter" even applies to a given property, and would close the gap where a value
happens to look numeric but isn't guaranteed to parse as one. This is useful even without
federation in the picture, cross-origin comparison just makes the gap load-bearing instead of
cosmetic.
**Origin metadata/versioning.**
If an origin represents an imported dataset, giving it its own version or changelog would let a
server know when the upstream taxonomy it imported has moved on, and decide explicitly whether to
re-import, rather than silently drifting from what other servers using the "same" origin now have.
This only helps if everyone's still on one shared timeline, though, it doesn't by itself address
diverging forks/branches ending up with the same name (see below).
**Pin origins to immutable content, not movable names.**
The `git:ee2` collision happens because the origin string names something mutable (a branch/tag)
rather than something fixed. If an origin string were derived from the content itself, e.g. a hash
of the definition, or a specific immutable commit rather than a branch, two independent imports
could never end up with the same string unless the content was actually identical at that point,
collisions would become structurally impossible rather than just unlikely. This is a bigger change
than the versioning idea above: it's not tracking change over time, it's making the identifier
itself incapable of silently referring to different things.
A concrete version of this: use the git blob hash of the file a property/tag/category was defined
in as (part of) its origin. If the reference dataset already lives in a git repo, this is free,
git's already computed it, and it's independently checkable, any server holding or able to fetch
the same repo can recompute the hash from the content and confirm for itself, rather than trusting
a label. This is a real improvement over free-text `origin` strings, but it isn't a free lunch:
- *Granularity.* A blob hash identifies a whole file, not a single property. If a file defines
several properties together, editing any one of them changes every other property's "identity"
in the same file too, even though nothing about them changed. Either definitions need to be
one-per-file for the hash to mean what's intended, or the hash needs to cover just the relevant
entry rather than the literal git blob.
- *It converts every edit into a fork.* Since any change, including a typo fix, changes the hash,
a routine upstream correction mechanically fragments what's still the same property into two
handles. That's consistent with "never silently reinterpret a handle," but it means the aliasing
mechanism above stops being a nice-to-have and becomes the primary upgrade path, every legitimate
edit needs an explicit "supersedes" link, or old items are stranded on a stale, now-orphaned hash.
- *A hash alone is an identity, not a location or a label.* It proves two things are the same (or
aren't), but doesn't say where to fetch the content from if you don't already have it, and isn't
human-readable. Pairing it with a location (which repo) and a mnemonic (which release/name it
corresponds to) alongside the hash keeps the discoverability goal intact instead of trading it
away for collision-proofing.
**Detect collisions on contact, as a backstop.**
Even with better-behaved identifiers going forward, existing data and human-typed origin strings
mean collisions can't be ruled out entirely. Whenever two servers interact over a handle they both
claim to know (e.g. as part of resolving an alias, or federated search), comparing a fingerprint of
the full definition, not just the handle string, would let a mismatch surface as an explicit
conflict to resolve, rather than being silently trusted. This is the same instinct as the "explicit
alias must state whether things are comparable" idea above, applied in the opposite direction, here
the handles already match and the system needs to actively check whether that trust is warranted.
The needed ingredient already exists locally and just isn't being used this way: the sha256 hash
already computed and stored per import (see above) is exactly the kind of definition fingerprint
this needs. Two changes would make it actually do the job: first, comparing it on every import
(including a "re-import" of a name already on record), not just recording it once, so a locally
edited file gets caught before it's ever presented to anyone else, and second, exchanging it as
part of whatever federated interaction references a shared-origin handle, so two servers can
compare hashes for the same name and find out they've diverged instead of assuming they haven't.
**What this means for the handle actually on the wire.**
Putting the above together, the everyday handle shouldn't change shape at all. It stays
`origin#type:name`, e.g. `git:ee2#property:charging_voltage`, exactly as it is today. The reason is
redundancy: this string is what appears on every single reference (every item's tag list, every
property assignment), potentially many times per item across many items, while a hash only ever
needs to be known once per origin. Carrying a full hash on every occurrence would repeat the same
value over and over for no benefit beyond what knowing it once already provides.
Instead, the hash stays where it already lives, attached to the origin as a whole (extending
`ImportedIdentifierSets`, see above), and gets exchanged at the points where two servers actually
need to agree on one, e.g. the first time a friend's item references an origin a server doesn't
already have a hash on record for. First contact just records it, same as resolving any unfamiliar
handle today; a later mismatch against what's on record is the collision, and that's the point
where it needs to become visible rather than silently trusted.
Only once a collision has actually been found does the wire format need to say more than
`origin#type:name`, because at that point there genuinely are two different things sharing a name
and something has to distinguish them for a person sorting it out. A short, abbreviated hash
appended to the origin, the same idea git itself relies on for short commit hashes, keeps that
escape hatch usable: `git:ee2~0f3a9c1e#property:charging_voltage` versus
`git:ee2~7bc82a04#property:charging_voltage`. This longer form is exception-path plumbing for
resolving an already-detected conflict, not something that changes the size or shape of handles in
the common case.
**Search across known origins.**
Before creating a new tag/category, a creation flow could search across origins the local server
already knows about (its own, plus any it's imported) and surface likely existing matches. This is
a UX/workflow fix rather than a change to the handle model itself, it doesn't need to touch
resolution semantics at all.
**Display name vs. handle.**
Separating "the name that appears in the handle" (stable, part of the identity) from "the label
shown to a user" (translatable, cosmetic) would allow localization without affecting resolution or
uniqueness, since resolution would stay keyed on the handle, not the display string.

View file

@ -97,6 +97,12 @@ Start the fullstack application:
docker-compose -f deploy/docker-compose.override.yml up --build
```
Run backend tests in Docker:
``` bash
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
```
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
server is used to direct the frontend to the correct backend instance.

View file

@ -20,4 +20,97 @@ uses it to verify access to the friend's inventory. While accepting a friend req
their own public key to the friend's server. This way both users can access each other's inventory.
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
## Unique Handles
Federation only works if every server can talk about the same thing without a central authority to ask. Toolshed's
answer is that every kind of entity that needs to be referenced across servers gets a handle: a name that is unique
within its own scope and that carries, as part of itself, enough information to say where it is authoritative. This
keeps servers independent of each other while still letting them agree on what they're talking about.
### Users (and Groups)
A user's handle is their username paired with the domain their account belongs to, written the way an email address
is, e.g. `user@toolsheddomain.tld`. Uniqueness is only required within a single domain, not across all of Toolshed,
so two different domains can each have their own "alice" without conflict, the same way two different email
providers can each have an "alice" mailbox. The domain half of the handle is what makes the name globally
unambiguous, and it is also what tells any other backend where to look to find out who's currently authoritative for
that identity, i.e. which backend holds the account and can vouch for its public key.
Groups aren't implemented yet, but they're intended to fit the same idea: a group would get its own handle on the
domain of the server that hosts it, the same way a user does, so that group membership and group-owned data could be
referenced by other servers without needing a separate mechanism. A group handle is written with a leading `#`, e.g.
`#groupname@toolsheddomain.tld`, so that group and user handles occupy visibly distinct spaces on the same domain
and a name can't be squatted as one to collide with the other. See [groups.md](design-in-progress/groups.md) for
details.
### Servers
The domain half of a handle, e.g. `toolsheddomain.tld`, is an authority record, not a location. Owning a domain
just means being able to say which backend is currently authoritative for handles under it; it says nothing about
where that backend is hosted, who operates it, or how many other domains it might also be authoritative for. A
single backend can just as easily host entities for one domain or for many unrelated ones at once, there's no
assumption anywhere in the model that a domain and a backend are the same thing, or that the relationship is one to
one.
The frontend application is a third, separate thing again. The app a user loads isn't necessarily served by, or
even related to, the backend that ends up handling their requests: when given a handle, the frontend looks up which
backend is currently authoritative for that handle's domain and talks to that backend directly from then on. So
using the frontend at one domain to log into a backend authoritative for a completely different domain isn't a
special case, it's the normal path, since "where the app was loaded from" and "which backend answers for a given
handle" were never the same question to begin with. Servers, in the cryptographic sense described below, don't have
an identity of their own beyond the handles they're currently authoritative for; a backend is, conceptually, just
wherever a given domain's handles happen to resolve to right now.
### Tags, Properties, and Categories
Inventory items aren't just described in free text, they can be classified with tags, properties, and categories,
and those get handles too, written as an origin followed by the kind and name, e.g. `origin#tag:drill` or
`origin#category:power-tools`. This lets the same short name (e.g. a "drill" tag) exist independently under
different origins without colliding, while a handle as a whole unambiguously says which taxonomy an entry belongs
to.
An origin isn't necessarily a server; it's whatever the classification is considered to have come from, which could
be a shared, canonical reference dataset that multiple servers import and reuse, just as easily as it could be a
server's own locally-invented taxonomy. This lets independently-run servers converge on a shared vocabulary where it
matters, without forcing every server to invent its own from scratch or requiring a central body to define one.
Handles are resolved strictly: a reference to an origin or entity a server doesn't know about is left unresolved
rather than being guessed at or silently merged into something that looks similar. This mirrors the rest of
Toolshed's federation philosophy, nothing is combined across servers implicitly; agreement always has to be
traceable to an explicit, shared handle.
### Items
Inventory items don't get a handle of their own the way tags or categories do, because they don't need one: every
item belongs to exactly one user, so a simple local identifier is already enough to tell two items apart within that
user's inventory. Combined with the owner's user handle, that local identifier is automatically unique across all of
Toolshed too, since no two users share a handle. Unlike a tag or category, an item isn't meant to be the same entity
reused across servers, it describes something one specific person actually owns, so there's no shared-origin concept
to design for here, ownership alone already provides the scope.
## Cryptography
Handles say who or what is being referred to; cryptography is what lets a server trust that the entity behind a
handle really is who it claims to be, without needing to ask a central authority. Every user handle has exactly one
asymmetric keypair backing it: a private key that never leaves the user's control, and a public key that gets handed
out freely as part of establishing that handle elsewhere.
A server first learns a public key at the moment it has reason to trust it: for its own users, that's registration;
for a friend's handle, that's the friend-request/accept exchange described above. From then on, a public key is
permanently paired with the handle it arrived with, never with a server. This is why friending is really a
key-exchange ceremony rather than just a social action, accepting a request is the moment a server starts trusting a
new handle's signature.
Every request made on a user's behalf is signed with that user's private key, and whichever server receives it
verifies the signature against the public key it holds for that handle. This is what makes it safe for a request to
travel to a server that isn't the user's home server: the receiving server doesn't need to trust the network path or
the sender, only the signature.
It's worth being explicit about what this layer of cryptography is for and what it isn't. Signing establishes
authenticity and integrity, that a request genuinely came from the handle it claims to, unaltered, not
confidentiality. The data itself isn't encrypted by the protocol; keeping it private in transit is what the
underlying HTTPS layer is for. Only user handles carry a keypair; tags, categories, properties, and item handles are
just names, their trustworthiness comes entirely from being reachable only through a signed request from the user
handle that owns or created them, not from any cryptographic identity of their own.

141
docs/glossary-todo.md Normal file
View file

@ -0,0 +1,141 @@
# Glossary terminology TODO
Working list from a repo-wide audit of where code/docs use a different word for a concept that
[glossary.md](glossary.md) already gives a canonical name. Nothing here has been changed yet, this
is a collection point before any renaming/edit work starts. Grouped by glossary term; only real
inconsistencies are listed, not every correct usage that was checked and cleared.
## Backend
- `frontend/src/federation.js` — the whole module (`class ServerSet`, `add(server)`, every
request method) talks about "server" throughout where the concept is a **Backend**.
- `frontend/src/store.js``getHomeServers`, `getFriendServers`, `getAllKnownServers`,
`setAllFriendsServers`/`all_friends_servers`, `home_servers`, `lookupServer` (~line 313, 325,
332, 359, 76-77, 107-108, 334).
- `frontend/src/views/Friends.vue:16,43` — user-visible table column labeled "Server".
- `docs/design-in-progress/tags.md` (lines 10, 20, 45, 76-78, 117, 204, 228-229) — "server" used
throughout for what the glossary calls Backend (same looseness federation.md already has, but
worth normalizing here too since tags.md is in active editing).
- `issues.md:58` — "federated home servers" / "user identity" conflation.
- `deploy/dev/docker-compose.yml` (`instance_a`/`instance_b`) and `docs/development.md:106-108`
"instance"/"backend instance" as a near-synonym for Backend.
- `cli-client/toolshed-client.py:11-70`, `README.md:92``--host`/`self.host` for "which backend
to talk to".
## Discovery
- `frontend/src/store.js:313``lookupServer` action *is* the discovery operation, never named
"discovery".
- `frontend/src/store.js:347``could not resolve server for friend` — "resolve" used instead.
- `docs/design-in-progress/items-labels.md:99`, `docs/development.md:108` — describe the
discovery operation via "resolves"/"direct the frontend to the correct backend" without naming
it (minor, but candidates for a one-word tightening).
## Handle / User handle
- ~~`backend/authentication/models.py` (`class KnownIdentity`...), `signature_auth.py`
(`author_identity`...), `frontend/src/identity.js` (`serializeIdentityRecord`...)~~ — no longer a
finding: the glossary now has an explicit **Identity** entry (handle + keypair, held together as
the unit a backend trusts), and this is exactly what these already name. No renaming needed here;
if anything, these are the parts of the codebase the new Identity entry should point to as
reference implementations.
- `backend/toolshed/serializers.py:49-57` (`FriendSerializer`) — API field is literally named
`"username"` but its value is a full handle (`username + '@' + domain`). Already
self-acknowledged in a comment at `frontend/src/store.js:404`. **Highest-value single fix**
it's a live API contract, not just an internal name.
- `frontend/src/store.js` — several action params destructured as `{username}` that actually carry
a full handle: `lookupServer` (313), `getFriendServers` (359), `fetchFriendProfile` (401-405),
`login` (276-282).
- `frontend/src/views/Login.vue` (lines 24, 27-28, 82-83, 102-105, 115-117) — form label/variable
"Username" for a field that must be a full user handle (`user@domain`, per its own validation
message at line 103).
- `frontend/src/router.js:51` — route param `/inventory/shared/:user/:id` uses `:user` for what's
meant to eventually be a full handle; contrast with the sibling route at line 61 which already
correctly uses `:handle`. (Already called out by items-labels.md itself, so low-risk to leave
as-is, but listed for completeness.)
## Availability policy, Friend/Friendship, Signature/Signing, Strict resolution, Actor, Targeted sharing
No real inconsistencies found — implemented code already uses the glossary's own terms
consistently (`availability_policy` field name throughout backend+frontend; `friend`/`befriend`
consistently; `Signature`/`sign`/`verify` consistently; `_HandleNotFound`/`_resolve_handle` in
`backend/toolshed/offlinedata.py` implement strict resolution faithfully without needing to name
it; Actor and Targeted sharing are unimplemented with no competing name anywhere).
- Checked and cleared, not a real conflict: `frontend/src/neigbors.js`'s `NeighborsCache`/
"neighbor" vocabulary — refers to unreachable backend *domains* during discovery, not to
friendship, despite reading like a synonym at a glance.
## Group / Group handle / Membership list
- `backend/backend/settings.py:36``django.contrib.auth` ships a built-in `Group` model, shown
as "Groups" in the Django admin. Not unregistered anywhere. Will collide by name with the
proposed actor-type Group once that's implemented — worth a decision now (unregister the
built-in admin Group, or otherwise disambiguate) before the real feature lands.
- `issues.md` (issue #3, "Group Concept", ~lines 62-156) — a standalone proposal that conflicts
with the already-settled `docs/design-in-progress/groups.md` design on three points at once:
- `Group.public_key`/`private_key` fields (contradicts "does a group need its own keypair? No").
- `GroupMembership` backed by a signed `membership_certificate` rather than a plain membership
list (contradicts the glossary's Membership list entry).
- Bare `Group.handle` strings with no `#` prefix, e.g. `"makerspace-nord"`, and a
`GroupInvitationIncoming.group_handle` field/API surface (`POST /api/groups/` etc., ~lines
65-66, 117-121, 150-156) that never uses the `#groupname@domain` shape.
This is a design-conflict issue, not a wording tweak — `issues.md` should be reconciled with (or
explicitly marked superseded by) `groups.md` before anyone implements from it.
## Keypair / Private key / Public key
- Wire-format drift on the one field that actually crosses the network: `befriender_key` is used
for a public key at `frontend/src/store.js:435,449` and `backend/toolshed/api/friend.py:107`,
while the model field, serializer field, and UI all call the same value
`befriender_public_key`/`public_key` (`backend/authentication/models.py:144`,
`backend/toolshed/serializers.py:65`, `backend/toolshed/api/friend.py:118`,
`frontend/src/views/Friends.vue:81`).
- `cli-client/toolshed-client.py` (`--key`, `TOOLSHED_KEY`, `self.signing_key`, ~lines 12, 52, 57)
and `README.md:92` — never say "private key," just "key"/"Toolshed key", even though it's
specifically the private half.
## Origin
- `backend/configure.py:130` ("Identifier set {} already imported, skipping") and the model
`ImportedIdentifierSets` (`backend/hostadmin/models.py:13-19`) — call an imported origin dataset
an "identifier set".
- `issues.md:206-210` — Instance Admin TODO list: "identifier-sets" for **Origin** and bare
"identifiers" for **Classification handle**, both alternate terms not matching glossary names.
## Alias
- `backend/shared_data/ee_packages.json:40,64` — two tags already carry an `"alias"` field in real
data (e.g. `SOT54``alias: "TO-92"`), but shaped as a bare name string, not an
`origin#type:name` handle pointer, and with no unit-conversion concept. It's silently dropped on
import today (`Tag`/`TagSerializer` have no `alias` field). Not a different-word issue, but the
design doc (which calls Alias "Proposed") doesn't acknowledge this pre-existing, inert
precedent — worth reconciling either the data or the doc.
## Tag / Property / Category
- `frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue:495` — CSV
column-auto-mapping heuristic treats `"type"` as a synonym for Category:
`lowerColumn.includes('category') || lowerColumn.includes('type')`.
## Item Handle
- `frontend/src/views/Search.vue:30,52,110` — a field literally named `handle` is computed here
(`e.owner==this.user ? e.id : "shared/"+e.owner+"/"+e.id`), but it's a router-path fragment, not
an Item Handle: no domain-qualified `user@domain.tld:id` shape, and `e.owner` is a bare username.
Whoever implements the real Item Handle later is likely to collide with this existing variable.
## Item Label
- `frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue:567-572,822` and
`FotoFirstBulkImportWorkflow2.vue:593-598,847` — checkbox "Generate QR codes for items" /
`importOptions.generate_qr_codes` names exactly the Item Label concept but never uses that term
(and the option is currently unwired — declared and defaulted `true` but never read elsewhere).
## Item URL / Local id / Domain / Frontend / Definition fingerprint / Fragmentation / Handle collision / Classification handle
No real inconsistencies found — each already uses consistent, glossary-matching vocabulary
(`id`/`item_id` for Local id; `origin` kept cleanly separate from `domain` everywhere it's used;
`get_handle()` consistently for Classification handle; no competing names found anywhere for
Fragmentation, Handle collision, or Definition fingerprint, which also doesn't collide with the
unrelated `File.hash` content-hash field despite both being called "hash").

229
docs/glossary.md Normal file
View file

@ -0,0 +1,229 @@
# Glossary
This page collects one unambiguous name for each concept discussed in [federation.md](federation.md)
and the [design-in-progress](design-in-progress/) documents, so later writing can refer to them
consistently instead of reinventing or subtly renaming them. Entries are grouped by topic, and
alphabetical within each group. Each one notes whether the concept exists in Toolshed today or is
still a design proposal, and links back to where it's discussed in full.
Two terms are easy to conflate and worth telling apart up front: a **domain** is who a handle
belongs to; an **origin** is where a classification entry came from. They look similar in prose but
are unrelated concepts, see their entries below.
---
## Federation Basics
**Backend** (Implemented)
The server software that stores an [actor](#actor)'s data and is currently authoritative for a
[domain](#domain). Deliberately decoupled from both the domain (a backend isn't tied to one domain,
and can be authoritative for many at once) and the [frontend](#frontend) (the app a user loads isn't
necessarily served by the backend that ends up handling their requests). Prefer "backend" over the
looser word "server" when precision matters, "server" is used informally in places to mean either
backend or domain interchangeably.
*See: [federation.md](federation.md#servers)*
**Discovery** (Implemented)
The lookup a [frontend](#frontend) performs to find which [backend](#backend) is currently
authoritative for a [domain](#domain), given a [handle](#handle). Operational/DNS-level detail
about how this lookup works belongs in the deployment docs, not here, this glossary only fixes the
name for the concept.
*See: [federation.md](federation.md#servers)*
**Domain** (Implemented)
The half of a [handle](#handle) after the `@`, e.g. `toolsheddomain.tld`. An authority record, not
a location or a piece of software: it says which [backend](#backend) currently vouches for handles
under it, nothing more. Not the same thing as an [origin](#origin), see the note at the top of this
page.
*See: [federation.md](federation.md#servers)*
**Frontend** (Implemented)
The client application a user interacts with. Independent of any one [domain](#domain) or
[backend](#backend): given a handle, it performs [discovery](#discovery) to find the right backend
and talks to it directly, regardless of where the frontend itself was loaded from.
*See: [federation.md](federation.md#servers)*
**Handle** (Implemented)
A name that's unique within its own scope and carries, as part of itself, enough information to say
where it's authoritative, without needing a central registry to look it up. The general term
covering [user handles](#user-handle), [group handles](#group-handle) (proposed),
[classification handles](#classification-handle), and [item handles](#item-handle) (proposed).
*See: [federation.md](federation.md#unique-handles)*
**Strict resolution** (Implemented)
The rule that a reference to a [handle](#handle) a backend doesn't recognize is left unresolved
rather than guessed at or silently merged into something that looks similar. The foundational
guarantee the rest of the handle system, and most of the open problems in
[tags.md](design-in-progress/tags.md), are trying to preserve or actually make hold.
*See: [federation.md](federation.md#tags-properties-and-categories)*
## Actors, Identity & Sharing
**Actor** (Proposed)
Umbrella term for anything that can hold a handle, have friends, and own items. Today this only
means [user](#user). [Groups](#group) are a proposed second kind of actor, so that "friendship,"
"ownership," and "handle" all mean the same thing regardless of which kind of actor is involved.
*See: [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
**Availability policy** (Implemented)
A setting on an item (`private` / `share` / `lend` / `rent` / `sell`) controlling who besides the
owner can see it. Today the audience for any non-`private` policy is implicitly "all of the owner's
[friends](#friend-friendship)," equally, there's no way to name a narrower audience. See [targeted
sharing](#targeted-sharing) for the proposed alternative.
*See: [groups.md](design-in-progress/groups.md#interaction-with-availability-policy)*
**Friend / Friendship** (Implemented for users; proposed for groups)
A mutual, explicitly-established trust relationship between two [actors](#actor). Established by a
friend-request/accept exchange, which is also the point a [public key](#keypair-private-key-public-key)
is first trusted for that handle. Currently only exists between users; groups having friends, and
groups befriending groups, are proposed extensions of the same mechanism, not a new one.
*See: [federation.md](federation.md#cryptography), [groups.md](design-in-progress/groups.md#should-a-group-be-able-to-grant-read-access-to-non-members-group-friends)*
**Group** (Proposed)
A second kind of [actor](#actor), modeling collective ownership (a club, workshop, or company)
rather than any one person owning something. All members hold equal edit rights over what the group
owns; membership itself is the privilege, there's no separate owner/member distinction within a
group. Backed by a [membership list](#membership-list) rather than a [keypair](#keypair-private-key-public-key),
and identified by a [group handle](#group-handle).
*See: [groups.md](design-in-progress/groups.md#what-a-group-is)*
**Group handle** (Proposed)
A [group](#group)'s handle: a name and [domain](#domain) written like a [user handle](#user-handle)
but prefixed with `#`, e.g. `#groupname@toolsheddomain.tld`. The prefix keeps groups and users in
disjoint namespaces on the same domain (no squatting collision between a user and a group wanting
the same name) and lets an actor's kind be read directly off its handle, without a lookup.
*See: [federation.md](federation.md#users-and-groups), [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
**Identity** (Implemented)
A [user handle](#user-handle) paired with the [keypair](#keypair-private-key-public-key) that backs
it, held together as the one unit a [backend](#backend) actually trusts: not just a name, and not
just key material, but both at once. This is what gets established at registration for one's own
handle, and what gets recorded on [friend](#friend-friendship)-accept for someone else's handle.
Only [users](#user) have an identity in this sense, since a [group](#group) is deliberately backed
by a [membership list](#membership-list) instead of a keypair, there's no key half for a group
handle to pair with.
*See: [federation.md](federation.md#cryptography)*
**Keypair / Private key / Public key** (Implemented, users only)
The asymmetric keypair backing exactly one [user handle](#user-handle); together, a handle and the
keypair backing it are what's called an [identity](#identity). The private key signs requests made
as that user and never leaves their control; the public key is handed out to establish trust in the
handle (at registration, or via a [friend](#friend-friendship) exchange) and is used to verify
signatures. Only user handles carry a keypair, not groups, classification handles, or item handles.
*See: [federation.md](federation.md#cryptography)*
**Membership list** (Proposed)
The record of which [user handles](#user-handle) currently belong to a [group](#group), maintained
by whichever backend is authoritative for the group's handle. What backs a group's identity in
place of a keypair: a request "as the group" is a normal signed request from a current member, plus
a check against this list, not a request signed by some shared group key.
*See: [groups.md](design-in-progress/groups.md#does-a-group-need-its-own-keypair)*
**Signature / Signing** (Implemented)
The act of authenticating a request as genuinely coming from a specific [user
handle](#user-handle), unaltered, using that handle's private key. Establishes authenticity and
integrity only, not confidentiality (that's HTTPS's job) and not, today, protection against replay.
*See: [federation.md](federation.md#cryptography)*
**Targeted sharing** (Proposed)
Sharing an item with one specific [actor](#actor) (a particular friend, or a particular group)
instead of the current all-or-nothing [availability policy](#availability-policy) audience of every
friend equally. Flagged as a generalization useful beyond groups specifically, not a groups-only
feature.
*See: [groups.md](design-in-progress/groups.md#should-anyone-be-able-to-share-directly-with-a-specific-group-instead-of-with-my-friends-generally)*
**User** (Implemented)
The original, and currently only implemented, kind of [actor](#actor): backed by exactly one
[keypair](#keypair-private-key-public-key) and identified by a [user handle](#user-handle).
*See: [federation.md](federation.md#users-and-groups)*
**User handle** (Implemented)
A user's username paired with its [domain](#domain), written like an email address, e.g.
`user@toolsheddomain.tld`. Unique only within its domain, not across all of Toolshed. Contrast with
a [group handle](#group-handle), which is the same shape but prefixed with `#`. Paired with its
[keypair](#keypair-private-key-public-key), the two together are called an [identity](#identity).
*See: [federation.md](federation.md#users-and-groups)*
## Classification: Tags, Properties & Categories
**Alias** (Proposed)
An explicit, one-directional "supersedes"/"alias of" pointer from one [classification
handle](#classification-handle) to another, asserting they mean the same thing. Never inferred
automatically, always a deliberate act, in keeping with [strict resolution](#strict-resolution). For
a property alias specifically, also states a unit conversion (even if it's "identical unit, factor
1"); without one, the values behind the two handles aren't assumed to be comparable.
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
**Classification handle** (Implemented)
Umbrella term for a [tag](#tag-property-category), [property](#tag-property-category), or
[category](#tag-property-category) handle, of the form `origin#type:name`, e.g.
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [item
handle](#item-handle): it names a reusable classification concept, not an actor or an owned thing.
*See: [federation.md](federation.md#tags-properties-and-categories)*
**Definition fingerprint** (Partially implemented)
A hash of a classification entry's full definition, used to tell whether two [actors](#actor)
that both use the same handle actually mean the same thing. A sha256 of each `shared_data/*.json`
file is already computed and stored per import (`ImportedIdentifierSets.hash`), but it's only ever
recorded, not compared, so it doesn't yet catch a [handle collision](#handle-collision) in
practice.
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
**Fragmentation** (Known problem)
Two different [classification handles](#classification-handle) that mean, or were intended to
mean, the same real-world concept (different origins, or a locally-invented tag versus a shared
one). The opposite failure from a [handle collision](#handle-collision): visible and merely
wasteful, rather than silent and dangerous.
*See: [tags.md](design-in-progress/tags.md#problem)*
**Handle collision** (Known problem)
Two [actors](#actor) independently ending up with the exact same [classification
handle](#classification-handle) string backing two different definitions, e.g. two servers that
each imported `git:ee2` from what has since become diverging branches. The dangerous mirror image
of [fragmentation](#fragmentation): silent, because nothing about the interaction signals that
anything's wrong, both sides just say the same string.
*See: [tags.md](design-in-progress/tags.md#worked-example-the-same-handle-meaning-two-different-things)*
**Origin** (Implemented)
The first component of a [classification handle](#classification-handle), naming where that tag,
property, or category came from, e.g. `git:base` in `git:base#property:length`. Not necessarily a
server or a domain, it can equally be a shared reference dataset (like the files in
`backend/shared_data/`) or a server's own locally-invented taxonomy. Not the same thing as a
[domain](#domain), see the note at the top of this page.
*See: [federation.md](federation.md#tags-properties-and-categories)*
**Tag / Property / Category** (Implemented)
The three kinds of classification entity an item can reference, each identified by a
[classification handle](#classification-handle). A property additionally carries unit metadata
(`unit_symbol`/`unit_name`), though property *values* on an item are plain, undeclared-type
strings today.
*See: [federation.md](federation.md#tags-properties-and-categories), [tags.md](design-in-progress/tags.md)*
## Items & Physical Labels
**Item Handle** (Proposed)
The compact identifier for a specific item that's meaningful outside its owner's own account:
`user@domain.tld:id`, the owner's [user handle](#user-handle) plus a [local id](#local-id). Used
where it's already clear from context that it's a Toolshed item, e.g. inside the app, in exports,
in logs, so it doesn't need to spell that out or be openable on its own. Contrast with [Item
URL](#item-url), the self-contained form for when no such context can be assumed.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Item Label** (Proposed)
A physical, scannable encoding (QR code, barcode, or similar) of an item's [Item URL](#item-url),
meant to be printed and stuck on the physical object it refers to.
*See: [items-labels.md](design-in-progress/items-labels.md#goals)*
**Item URL** (Proposed)
The self-contained URL form of an [Item Handle](#item-handle), for use with no context at all, e.g.
an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`. Has to open directly
to the right frontend and land on the right item on its own, since the reader can't be assumed to
already know what it is or which server it belongs to. Any frontend can serve this URL, the host
named in it isn't part of the item's identity, only the handle in its path is.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Local id** (Implemented)
An item's identifier as it exists today: unique only within its owner's own inventory, not
meaningful outside that owner's account. The starting point both the [Item
Handle](#item-handle) and [Item URL](#item-url) build on.
*See: [federation.md](federation.md#items), [items-labels.md](design-in-progress/items-labels.md#problem)*

View file

@ -0,0 +1,168 @@
# Handles and Short IDs
This is the syntax-level reference for two related but separate naming schemes. [federation.md](federation.md)'s
"Unique Handles" section covers *why* Toolshed hands out handles at all and what each kind (user,
group, tag/property/category) means conceptually; this document covers the parsing rules those
handles have to follow once they're written down or embedded somewhere - legal characters and
escaping. It also covers short ids end to end: a separate, newer scheme for packing small integer
id chains into a compact token, implemented in `frontend/src/short-id.js`.
## Handle syntax
### Reserved characters
A username ends up embedded, unescaped, in several composite formats beyond its own handle, so it
can't contain any character that already means something else in one of those: `@` (the
user/domain separator in a user handle), `#` (the group-handle prefix, and the origin/type
separator in a classification handle, see federation.md's Tags, Properties, and Categories
section), `:` (the id delimiter in a proposed Item Handle, the type/name delimiter in a
classification handle, and the delimiter in a signed request's `Authorization` header), `+`
(reserved as the URL-embedding escape for `#`, see below), `~` (the short-id token prefix, see
Short IDs below, and a proposed collision-disambiguation suffix delimiter on a tag's origin), and
`/` (the path-segment delimiter every handle and id ultimately sits next to once embedded in a
URL). This has to be enforced by an explicit validator rather than left to Django's default
`UnicodeUsernameValidator` (`^[\w.@+-]+\Z`), which currently permits both `@` and `+` (its own
regex doesn't happen to allow `#`, `:`, or `/`, but that's incidental, not a designed restriction).
### Embedding a `#`-bearing handle in a URL
A literal `#` can't appear unescaped in a URL path segment: per RFC 3986, `#` starts the URI's
fragment component, so any URL-parsing client (a browser, a QR scanner, a link preview) treats
everything from the first unescaped `#` onward as a fragment and never sends it to the server at
all, before a request is even made, not merely a server-side quirk to work around. The usual fix is
to percent-encode it (`%23`), but that's exactly the encode/decode step a self-contained item URL
(a physical label, a link shared outside the app) is designed to avoid for anything that sits
directly in a path segment (`@` needs no such treatment). Instead, whenever a handle containing a
`#` (a group handle, or a tag/property/category
handle) has to appear as a raw URL path segment, substitute `+` for `#` in that rendering only:
`#groupname@domain` becomes `+groupname@domain` in a URL, and `origin#type:name` becomes
`origin+type:name`. This is a URL-embedding convention, not a second handle format: the canonical
handle, the one used in the API, in signed requests, in the database, and everywhere else a handle
is written or displayed, is unchanged and is still written `#groupname@domain`. Reversing the
substitution when parsing a path segment back into a handle is unambiguous only because `+` is
otherwise forbidden in every field a handle is built from (see Reserved characters above); if a
group name or tag name could itself contain a literal `+`, it would be indistinguishable from an
escaped `#` once decoded.
Implemented in `frontend/src/handle-url.js` (`encodeHandleForUrl`/`decodeHandleFromUrl`).
## Short IDs
A general encoding for turning a small, fixed-shape list of integers into a compact, URL-safe
token, with no server-side lookup table involved: the code *is* the data, nothing is stored
server-side to make it resolvable. Originally proposed to answer items-labels.md's open "what does
the handle/URL actually look like" question, but the encoding itself isn't item-specific; anything
currently addressed by a short chain of small integers is a candidate. Implemented and tested in
`frontend/src/short-id.js`; try it live at `/~<token>` (`frontend/src/views/ShortId.vue`), which
decodes whatever token is in the URL and also lists worked examples for every registered kind.
### Shape: a kind tag, then a fixed list of integers
Every short id starts with a small, fixed-width **kind** tag saying which schema the rest of the
bits should be read against, followed by exactly the integer fields that kind's schema calls for,
in a fixed order. `kind` is a small, closed, slow-growing set, so it doesn't need to be
self-delimiting the way the integer fields do: 2 bits directly name kinds 0-2, and the all-ones
value (3) is an escape meaning "the real kind follows as the next field, offset by this direct
range" - so kind 3 is encoded as escape + chunked-int `0`, kind 4 as escape + `1`, and so on. This
costs nothing for a kind that already fits in the direct range, and keeps the tag itself extensible
forever without ever having to widen it out from under codes that were already printed. A narrow
tag only pays off if kind usage is actually skewed the way id values are (a few kinds dominate),
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first:
| kind | name | fields | notes |
|---|---|---|---|
| 0 | `item` | `owner_identity_id`, `item_local_id` | dominant case - the primary physical-label use case |
| 1 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
| 2 | `category` | `category_id` | label-adjacent (tagging); global, no owner |
| 3 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - first to pay the escape's cost |
| 4 | `group` | `group_id` | shared even less often; global, no owner |
| 5 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
`owner_identity_id` is `KnownIdentity.pk` (`backend/authentication/models.py`), not
`ToolshedUser.pk`. Every local account already has exactly one stable `KnownIdentity` row
(`ToolshedUser.public_identity`, created once at registration and never recreated), and every
friend this backend knows about - local or remote - is represented by that same table, unique on
`(username, domain)`. So one small integer already stands in for "this owner, as known by this
backend" for both cases, with no separate local-vs-remote branching needed, and it's the same row
federation.md's Cryptography section already treats as the trust anchor for a handle's public key.
It appears on `item`, `storage_location`, and `workflow` because their backing models
(`InventoryItem`, `StorageLocation`, `WorkflowInstance`) all FK `ToolshedUser` directly; `category`,
`group`, and `file` skip it because their models are global/unscoped (`Group` has an unowned
`members` M2M, `File` is deduplicated globally by content hash), so a bare row id is already
everything needed to look them up.
A short id is inherently scoped to the backend that minted it (an "owner" field is a row that only
exists in, and only means anything to, that one backend's database), not a portable replacement for
a `user@domain.tld` handle, which stays the form to use anywhere cross-domain resolution actually
matters. Resolving a short id still goes through the same friend/signature checks as everything
else, unchanged; nothing about how the code looks grants any authority of its own (see Guessability
below).
### Packing one integer: dynamic bit depth
Each integer field is made self-delimiting with **continuation chunking** (UTF-8/LEB128-style):
split the value into fixed-size chunks (4 data bits each, most-significant chunk first), each
preceded by one continuation bit meaning "another chunk follows" (`1`) or "this is the last chunk"
(`0`). A value like `42` (`0b101010`) needs two 4-bit chunks, costing 10 bits total (2 × (1
continuation + 4 data)); `7` fits in one chunk, costing 5 bits. This was chosen over an
Elias-gamma-style unary/delimiter scheme (encode the value's bit-length in unary, then that many
literal bits): unary is cheaper for single-digit values but its prefix grows every time the value's
bit-length grows, so it never wins once ids pass single digits, which is the common case here (auto
increment database ids realistically sitting in the tens through low-hundred-thousands over an
installation's life). A 4-bit chunk width is a reasonable fixed default across that whole range;
per-field tuning was checked against both a uniform and a skewed (geometric) distribution and never
won by more than a fraction of a character, not enough to justify a tuning knob.
### From bits to text: base64 without the byte layover
Standard base64 assumes byte-aligned (8-bit) input, grouping 3 bytes into 4 output characters and
padding to a byte boundary before encoding. Since there's no byte layer here to begin with, the
bit-packed stream is instead packed directly into 6-bit groups and mapped straight onto the
URL-safe base64 alphabet (RFC 4648 §5: `-` and `_` in place of `+` and `/`), with the final
character's unused low bits padded with zeros. That padding is safe by construction: the decoder
always knows exactly how many integers a given kind calls for, and a chunk's continuation bit is
`1 = more follows`, so a run of zero-padding at the very end can never be misread as "one more
chunk" - it decodes as a terminated chunk, at which point every field the schema called for has
already been produced and decoding simply stops. No `=` padding characters are needed either; those
exist in classic base64 purely to communicate trailing-byte padding, and there is no byte layer
here to need that.
### The leading `~`
Every token is prefixed with a literal `~`, so a short id in a URL looks like `~DyU`. Its only job
is to mark "everything after me decodes as one of these": URL-safe base64 never produces a `~`
itself, so the prefix can never be confused with the payload, and none of Toolshed's other
path-segment formats (bare usernames, `user@domain` handles, slugs, plain numeric ids) start with
`~` either. `~` is one of RFC 3986's `unreserved` characters (§2.3, the same class as letters,
digits, `-`, `.`, and `_`), a stronger guarantee than merely being legal in a path segment: it's
never a target for percent-encoding and never carries special meaning in any URI component, so a
short id can be handed to any part of the stack without first checking which encoding rules apply
there.
### Worked examples
Encoding `kind = item` (0), `owner_identity_id = 7`, `item_local_id = 42`:
- `kind`: 2 fixed bits → `00`
- `owner_identity_id = 7`: fits in one 4-bit chunk → 5 bits (`00111`)
- `item_local_id = 42`: needs two 4-bit chunks → 10 bits (`1001001010`)
Total: 17 meaningful bits, padded to the next multiple of 6 (18) with one zero bit, yielding 3
base64 characters: **`~DyU`**.
The same worked-out form for one example of every registered kind - `Bits` is the same
space-separated segmentation (kind tag, escape offset if present, each field, then padding) the
Examples table on `/~<token>` (`frontend/src/views/ShortId.vue`) shows for every registered kind:
| Kind | Fields | Serialized | Bits | Token |
|---|---|---|---|---|
| `item` | `owner_identity_id: 7`, `item_local_id: 42` | `[0, 7, 42]` | `00 00111 1001001010 0` | `~DyU` |
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[1, 3, 1000]` | `01 00011 100111111001000 00` | `~Rz8g` |
| `category` | `category_id: 5` | `[2, 5]` | `10 00101 00000` | `~ig` |
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wCS` |
| `group` | `group_id: 11` | `[4, 11]` | `11 00001 01011` | `~wr` |
| `file` | `file_id: 123` | `[5, 123]` | `11 00010 1011101011 0` | `~xXW` |
`workflow`, `group`, and `file` are kinds 3-5, so their `Bits` column shows the escape tag (`11`)
followed by its own offset segment payload fields.

Some files were not shown because too many files have changed in this diff Show more