Compare commits
67 commits
stable
...
jedi/proto
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f3dc7c993 | |||
| 23185e1721 | |||
| 4c9e8f942e | |||
| 8de6ae8d8b | |||
| 35be834799 | |||
| 9ce700c388 | |||
| 582853ab27 | |||
| 7c2db40eab | |||
| 491ee05f15 | |||
| 1356aa7749 | |||
| 368268d288 | |||
| fb3c1e78e8 | |||
| 0c025db799 | |||
| f87b689e27 | |||
| aa94c92000 | |||
| ed04d98bf1 | |||
| 8d96bc97c4 | |||
| 8f3236b5b4 | |||
| 54374abf86 | |||
| bbe52e4a78 | |||
| 5c3b7fc252 | |||
| de7d9426be | |||
| 3299c97392 | |||
| 7d9f67a77a | |||
| 395a9b156a | |||
| c345372382 | |||
| ce1e5f1d62 | |||
| 67c7415c3b | |||
| 4787acd8eb | |||
| 6d2167ac66 | |||
| eaae7c286a | |||
| 8622e488a4 | |||
| 95ddb484eb | |||
| accbaf3603 | |||
| 7d7730354e | |||
| 25cef95711 | |||
| 9803f23b8d | |||
| 312f2f6460 | |||
| d32e718454 | |||
| dae1528793 | |||
| 419893d93d | |||
| 0f51f6e33f | |||
| 3b494dfa37 | |||
| 82a27ce2a8 | |||
| c0f70004eb | |||
| 6fcdd1eefa | |||
| 4f2fe011c0 | |||
| cfcc2c15d3 | |||
| 2f8683add1 | |||
| 32addb8ed1 | |||
| a9d5bbb9df | |||
| cbceaaf9dc | |||
| 7df585d774 | |||
| 796fef81be | |||
| 7c91661be2 | |||
| 4627f0aca2 | |||
| 2fe8d14c2c | |||
| 17bfb84a94 | |||
| 448c166507 | |||
| 0af1f40b07 | |||
| f01d513803 | |||
| b8f1942ab1 | |||
| 8716d3f692 | |||
| 9770ca861a | |||
| 157e47d3ef | |||
| c49a40df01 | |||
| ca79af5e38 |
238 changed files with 87113 additions and 2237 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "frontend/extras"]
|
||||
path = frontend/extras
|
||||
url = https://git.neulandlabor.de/j3d1/vue-extras.git
|
||||
|
|
@ -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
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
|
||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||
|
||||
SERVE_X_ACCEL_REDIRECT=True
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
26
backend/authentication/migrations/0003_accountpreference.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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')},
|
||||
),
|
||||
]
|
||||
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
28
backend/authentication/migrations/0006_groupmembership.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,25 +32,38 @@ def yesno(prompt, default=False):
|
|||
|
||||
|
||||
def configure():
|
||||
if not os.path.exists('.env'):
|
||||
if not 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')
|
||||
# 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
|
||||
|
||||
env = dotenv.load_dotenv('.env')
|
||||
if not env or not os.getenv('SECRET_KEY'):
|
||||
if not os.path.exists('.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
|
||||
|
||||
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')
|
||||
|
|
@ -59,7 +72,16 @@ def configure():
|
|||
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
|
||||
|
|
@ -183,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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
backend/shared_data/base.json
Normal file
59
backend/shared_data/base.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
92
backend/shared_data/ee.json
Normal file
92
backend/shared_data/ee.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
99
backend/shared_data/ee_packages.json
Normal file
99
backend/shared_data/ee_packages.json
Normal 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"
|
||||
}
|
||||
62
backend/shared_data/electrical.json
Normal file
62
backend/shared_data/electrical.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
82
backend/shared_data/it.json
Normal file
82
backend/shared_data/it.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
30
backend/shared_data/screws.json
Normal file
30
backend/shared_data/screws.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
68
backend/shared_data/tools.json
Normal file
68
backend/shared_data/tools.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
209
backend/toolshed/api/group.py
Normal file
209
backend/toolshed/api/group.py
Normal 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'),
|
||||
]
|
||||
28
backend/toolshed/api/idmap.py
Normal file
28
backend/toolshed/api/idmap.py
Normal 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'),
|
||||
]
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
253
backend/toolshed/api/offlinedata.py
Normal file
253
backend/toolshed/api/offlinedata.py
Normal 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'),
|
||||
]
|
||||
28
backend/toolshed/migrations/0007_workflowinstance.py
Normal file
28
backend/toolshed/migrations/0007_workflowinstance.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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=''),
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
37
backend/toolshed/migrations/0013_owneritemsequence.py
Normal file
37
backend/toolshed/migrations/0013_owneritemsequence.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
16
backend/toolshed/migrations/0015_inventoryitem_id.py
Normal file
16
backend/toolshed/migrations/0015_inventoryitem_id.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
16
backend/toolshed/migrations/0020_storagelocation_id.py
Normal file
16
backend/toolshed/migrations/0020_storagelocation_id.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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})"
|
||||
|
||||
|
||||
|
|
|
|||
578
backend/toolshed/offlinedata.py
Normal file
578
backend/toolshed/offlinedata.py
Normal 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
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
304
backend/toolshed/tests/test_group.py
Normal file
304
backend/toolshed/tests/test_group.py
Normal 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)
|
||||
48
backend/toolshed/tests/test_idmap.py
Normal file
48
backend/toolshed/tests/test_idmap.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
258
backend/toolshed/tests/test_offlinedata.py
Normal file
258
backend/toolshed/tests/test_offlinedata.py
Normal 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'})
|
||||
|
||||
48
backend/toolshed/tests/test_workflow_api.py
Normal file
48
backend/toolshed/tests/test_workflow_api.py
Normal 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
83
cli-client/toolshed-client.py
Executable 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()
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
110
deploy/dev/docker-compose.yml
Normal file
110
deploy/dev/docker-compose.yml
Normal 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
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
deploy/dev/instance_a/testdata.py
Normal file
108
deploy/dev/instance_a/testdata.py
Normal 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')
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
108
deploy/dev/instance_b/testdata.py
Normal file
108
deploy/dev/instance_b/testdata.py
Normal 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')
|
||||
|
|
@ -21,4 +21,4 @@
|
|||
"target": "127.0.0.2."
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
@ -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
3
deploy/prod/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.secrets/
|
||||
inventory.yml
|
||||
.frontend-build/
|
||||
33
deploy/prod/Dockerfile.backend
Normal file
33
deploy/prod/Dockerfile.backend
Normal 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"]
|
||||
26
deploy/prod/Dockerfile.frontend
Normal file
26
deploy/prod/Dockerfile.frontend
Normal 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/"]
|
||||
17
deploy/prod/Dockerfile.wiki
Normal file
17
deploy/prod/Dockerfile.wiki
Normal 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
224
deploy/prod/README.md
Normal 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.
|
||||
53
deploy/prod/inventory.example.yml
Normal file
53
deploy/prod/inventory.example.yml
Normal 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
677
deploy/prod/playbook.yml
Normal 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
|
||||
113
docs/design-in-progress/groups-mvp.md
Normal file
113
docs/design-in-progress/groups-mvp.md
Normal 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.
|
||||
233
docs/design-in-progress/groups.md
Normal file
233
docs/design-in-progress/groups.md
Normal 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).
|
||||
166
docs/design-in-progress/image-caching.md
Normal file
166
docs/design-in-progress/image-caching.md
Normal 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.
|
||||
131
docs/design-in-progress/items-labels.md
Normal file
131
docs/design-in-progress/items-labels.md
Normal 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.
|
||||
252
docs/design-in-progress/tags.md
Normal file
252
docs/design-in-progress/tags.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
141
docs/glossary-todo.md
Normal 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
229
docs/glossary.md
Normal 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)*
|
||||
168
docs/handles-and-shortids.md
Normal file
168
docs/handles-and-shortids.md
Normal 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
Loading…
Add table
Add a link
Reference in a new issue