diff --git a/backend/authentication/api.py b/backend/authentication/api.py index 599fea5..1e3f09c 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -18,8 +18,9 @@ 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. +# Schema for the account-level preferences a client may store on the server (see +# AccountPreference). Device-level preferences are never sent here - they stay in the +# browser's local storage since they describe the device, not the account. PREFERENCE_DEFINITIONS = [ { 'key': 'ui.compact_mode', @@ -101,8 +102,9 @@ class UserViewSet(viewsets.ModelViewSet): @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).""" + """Get or update the authenticated local user's own account info. Only usable by the + account owner on their own home server - see getUserProfile for viewing another (friend) + user's public profile.""" user = request.user if request.method == 'PATCH': old_file = user.profile_picture @@ -145,9 +147,9 @@ def getUserInfo(request): @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).""" + """Get another local user's public profile by handle (username@domain), e.g. so a friend + can look up someone's avatar. The caller must be a friend of that user (or the user + itself, signing with their own known identity rather than their local credentials).""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: @@ -211,9 +213,11 @@ def preference_definitions(request): @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.""" + """Get or bulk-upsert the authenticated user's account-level preferences. + + GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value} + dict of one or more preferences to set/overwrite; unspecified keys are left untouched. + """ if request.method == 'PUT': if not isinstance(request.data, dict): return Response({'detail': 'Expected an object of key/value pairs.'}, status=400) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index a19e759..e120ce8 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -114,8 +114,11 @@ class ToolshedUser(AbstractUser): 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.""" + """A single account-level (server-synced, cross-device) user preference, stored as a key/value pair. + + Device-level preferences are intentionally *not* stored here - they stay in the browser's + local storage since they describe the device, not the account. + """ user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences') key = models.CharField(max_length=255) value = models.JSONField() @@ -162,9 +165,9 @@ class Group(models.Model): 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).""" + """A pending invite tracked on the group's own home backend, checked when the invitee's + accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own + backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance).""" secret = models.CharField(max_length=255) group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites') invitee_username = models.CharField(max_length=255) diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index 1d2d247..2be90d3 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -81,8 +81,12 @@ def verify_incoming_friend_request(request, raw_request_body): 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.""" + """Self-certifying verifier for the two legs of the group invite/accept dance that land on a + backend which doesn't have the caller cached as a KnownIdentity yet (see + docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own + backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the + group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors + verify_incoming_friend_request exactly, just with configurable field names.""" try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) except ValueError: @@ -139,8 +143,10 @@ def authenticate_request_against_local_users(request, raw_request_body): class SignatureAuthentication(authentication.BaseAuthentication): def authenticate(self, request): 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. + # Returning a bare None (rather than a (None, None) tuple) tells DRF this + # authenticator doesn't apply, so it moves on to the next authenticator in the + # authentication_classes list instead of treating the request as authenticated + # with an empty user. if identity is None: return None return identity, None diff --git a/backend/backend/asgi.py b/backend/backend/asgi.py index c6fe45e..f4518a1 100644 --- a/backend/backend/asgi.py +++ b/backend/backend/asgi.py @@ -1,3 +1,12 @@ +""" +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 diff --git a/backend/backend/settings.py b/backend/backend/settings.py index db7893c..e56e4ab 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -1,3 +1,14 @@ +""" +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 @@ -8,9 +19,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent 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). + # deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at + # /git (separate from BASE_DIR, which only has the backend/ subtree) - + # point git at it explicitly there. Bare-metal dev has no such mount, but + # BASE_DIR sits inside the real checkout so plain rev-parse finds it by + # walking up. In prod, the build context is backend/ alone with no .git + # anywhere, so both fail and we fall back to the GIT_COMMIT build-arg/env + # var (see deploy/prod/Dockerfile.backend and playbook.yml). cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git'] try: return subprocess.check_output( @@ -21,6 +36,9 @@ def _git_commit(): dotenv.load_dotenv(BASE_DIR / '.env') +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + SECRET_KEY = os.environ.get('SECRET_KEY', None) if SECRET_KEY is None: raise Exception('environment variable SECRET_KEY not set. try running `configure.py` or setting it manually') @@ -109,6 +127,9 @@ TEMPLATES = [ WSGI_APPLICATION = 'backend.wsgi.application' +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -118,6 +139,9 @@ DATABASES = { 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', @@ -133,6 +157,9 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' @@ -141,18 +168,26 @@ 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 = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles') MEDIA_URL = '/media/' -# 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 +# In prod, nginx (running as www-data) reads these files directly - see # SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`. +# Pinned explicitly rather than left to the backend process's ambient umask, +# so group-read (www-data is added to the backend's system group) is +# guaranteed regardless of how the container is started. FILE_UPLOAD_PERMISSIONS = 0o640 FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750 +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' STORAGES = { diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 105a175..495aac1 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -1,3 +1,18 @@ +"""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 diff --git a/backend/backend/wsgi.py b/backend/backend/wsgi.py index 21d383b..c4aa324 100644 --- a/backend/backend/wsgi.py +++ b/backend/backend/wsgi.py @@ -1,3 +1,12 @@ +""" +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 diff --git a/backend/configure.py b/backend/configure.py index dde345c..cb04c8c 100755 --- a/backend/configure.py +++ b/backend/configure.py @@ -32,8 +32,11 @@ def yesno(prompt, default=False): def configure(): - # Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod - # container configured via --env-file) can still print them for the operator to apply manually. + # Keys this function may generate/update, tracked so that if .env turns + # out to be unwritable (e.g. a prod container running as an unprivileged + # user, whose real config comes from --env-file instead) we can still + # print the resulting configuration for the operator to apply manually, + # rather than silently discarding it or crashing. tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS'] unwritable = False diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index fe8bdc7..7c5e2ff 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -24,8 +24,9 @@ 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. + # Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see + # what - a file is visible if the requester is friends-or-self with whatever currently + # references it (an inventory item, a 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()) | @@ -34,7 +35,8 @@ def _accessible_files(request): def _cache_headers(etag): - # Content is hash-addressed and can never change under a given URL, so it's cacheable forever. + # Content is addressed by its own hash and can never change under a given URL, so caches + # (and the conditional-GET checks in both views below) can treat it as immutable forever. return { 'ETag': etag, 'Cache-Control': 'max-age=31536000, private, immutable', @@ -47,16 +49,23 @@ def _cache_headers(etag): @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. + # Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware, + # configured in settings.py) adds them to every Django response automatically, so + # setting them manually on these responses would just be redundant. The one exception + # is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it + # follows the X-Accel-Redirect and serves the file itself, so the CORS header for that + # case has to be configured in nginx's `location /redirect_media/` block 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). + # Looked up by the derived storage path (not by raw hash) because FileSerializer.name + # (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g. + # AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and + # the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too. try: file = _accessible_files(request).get(file=hash_path) - # Must run before this check, else a bare hash + If-None-Match would let anyone probe - # file existence for files they can't see. + # The access-control lookup above must happen before this check - otherwise a bare + # hash + If-None-Match would let anyone probe "does a file with this hash exist" for + # files they can't actually see. if request.META.get('HTTP_IF_NONE_MATCH') == file.hash: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) @@ -70,7 +79,9 @@ def media_urls(request, hash_path): **cache_headers, }) else: - # Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend. + # Read via the FieldFile itself (works against whatever storage backend is + # actually configured) rather than assuming file.file.path is a real filesystem + # path - the test suite swaps in an in-memory backend where that isn't true. with file.file.open('rb') as fh: content = fh.read() return HttpResponse(status=status.HTTP_200_OK, @@ -83,7 +94,9 @@ def media_urls(request, hash_path): def _thumbnail_rel_path(file_hash, size): - # Mirrors hash_upload()'s sharding under thumbnails//, reachable via the same nginx alias as originals. + # Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails//` + # subtree - reachable through the same nginx `/redirect_media/` alias as originals when + # served from real disk, no separate nginx location needed. return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6], file_hash[6:] + '.jpg') @@ -103,15 +116,22 @@ def thumbnail_urls(request, size, hash_path): 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. + # Read/write through the default storage backend, same as File.file itself, rather + # than a hand-rolled filesystem path - correct regardless of storage backend (real + # disk in production, in-memory under the test runner) and keeps the cache in the + # same place originals live. 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. + # Thumbnails are always re-encoded as JPEG regardless of the original format - + # smaller and simpler than preserving e.g. PNG 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. + # Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha, + # palette-with-transparency, RGBA) store meaningless color/luminance data under + # fully transparent pixels (often zeroed out, i.e. black). Converting straight + # to RGB reveals that instead of "nothing there"; compositing onto an opaque + # background first shows what the image is actually supposed to look like. rgba = image.convert('RGBA') flattened = Image.new('RGB', rgba.size, (255, 255, 255)) flattened.paste(rgba, mask=rgba.getchannel('A')) diff --git a/backend/files/models.py b/backend/files/models.py index a55f794..bff9121 100644 --- a/backend/files/models.py +++ b/backend/files/models.py @@ -43,7 +43,15 @@ 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. + # The upload path is derived entirely from the hash (hash_upload, above), and hash + # is DB-unique - so if no File row owns this hash yet, anything already sitting at + # its computed path is necessarily a stale orphan (e.g. left behind by a bug in + # cleanup code that deleted a File row without removing its stored bytes, or a + # crashed upload). Clear it before saving instead of letting Django's storage layer + # invent an alternate filename to avoid the "collision" - a suffixed name would + # silently break every part of the app that derives this file's URL purely from its + # hash (media serving, thumbnail generation, item/avatar attachment), and no future + # caller could ever discover it again. expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '') if default_storage.exists(expected_path): default_storage.delete(expected_path) diff --git a/backend/files/tests.py b/backend/files/tests.py index 8c160e9..05ecb38 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -113,7 +113,12 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase): 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. + # Reproduces a real incident: a File row gets deleted without its underlying stored + # bytes being removed (e.g. a bug in some cleanup call site), leaving an orphan sitting + # at the exact path hash_upload() would compute for that content. A later upload of the + # same content must land back on that canonical path - not get silently suffixed by + # Django's default collision-avoidance, which would make it unreachable to every part + # of the app that derives a file's URL purely from its hash. 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)) @@ -226,8 +231,11 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To 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. + # Each test method gets its own distinct image content (and therefore its own content + # hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within + # a run, so sharing one fixed image across methods risks one test's cached (or, in + # test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted) + # thumbnail leaking into another test's assertions. seed = zlib.crc32(self._testMethodName.encode()) % 256 buffer = io.BytesIO() Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG') @@ -246,7 +254,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To 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. + # Documents the fixed size allow-list this test suite exercises against - update both + # if files/media_urls.py's THUMBNAIL_SIZES ever changes. self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256)) @override_settings(SERVE_X_ACCEL_REDIRECT=False) @@ -265,7 +274,10 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To @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. + # Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent + # region has zeroed-out luminance underneath, as many image tools produce. Converting + # straight to RGB (dropping alpha without compositing) reveals that zeroed data - the + # whole thumbnail comes out solid black even though the visible (opaque) content isn't. half_transparent = Image.new('LA', (200, 200)) pixels = half_transparent.load() for x in range(200): @@ -298,7 +310,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To 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. + # Overwrite the cached file with a marker so a correct implementation must serve this + # exact content back rather than regenerating it from the original. default_storage.delete(rel_path) default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER')) @@ -320,7 +333,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To 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. + # local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the + # denied case needs a stranger to that friendship 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))) diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index 03b51d9..9b3b8d0 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -50,7 +50,10 @@ def post_item_file(request, 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. + # Attach a file the caller already staged on one of their own workflows, identified + # by its content hash (which the client already computed before ever uploading it), + # instead of re-uploading bytes that are already stored server-side. Workflows are + # always personally owned, so this only applies to a caller with a local account. if not request.user.user.exists(): return Response(status=status.HTTP_404_NOT_FOUND) try: @@ -71,7 +74,10 @@ def post_item_file(request, item_id): def get_staged_files(request, workflow_id): try: 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. + # Hash alone identifies a staged file (client and server hash content the same way, and + # bytes are fetchable from a hash-derived storage path) - useful mainly for discovering + # what another session/device already staged on this workflow, unlike the fuller + # FileSerializer representation item_files uses. return Response(list(workflow.staged_files.values_list('hash', flat=True))) except WorkflowInstance.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 833c00e..c0c119c 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -35,22 +35,26 @@ 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. + # Detail routes address an item by its owner-scoped id, not the internal row id - the + # router still names the URL capture group 'pk', so keep that as lookup_url_kwarg and just + # change which model field it's matched against. get_queryset() below is always already + # scoped to the requester's own items/groups, so this can't cross into another owner's ids. lookup_field = 'id' lookup_url_kwarg = 'pk' def get_queryset(self): - # 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. + # A KnownIdentity acting purely as a group member (e.g. a remote member on a group + # hosted on this backend) never has a local ToolshedUser account here - group-owned + # items must stay reachable for such an identity, only personal ("owner=...") items + # require .user.exists(). 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. + # retrieve/update/destroy: anything the caller may act on - their own items, or any + # group they're currently a member of. The narrower per-group listing below is only + # for the list action, so the main Inventory page stays scoped to personal items. if identity.user.exists(): return InventoryItem.objects.filter(owner=identity.user.get()) | group_items return group_items @@ -123,8 +127,13 @@ def search_inventory_items(request): @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// or - /inventory/shared//. See docs/implementation.md#get-shared-item-looks-up-by-owner.""" + """Fetch a single item by its owner's handle (username@domain) and local id, e.g. for the + /i// item URL (see docs/design-in-progress/items-labels.md) or the + /inventory/shared// in-app view. Unlike InventoryItemViewSet, which only ever + returns the requester's own items, this looks the item up by owner instead of by requester, + so it's the only endpoint that can serve a friend's item - subject to the same + friends-or-self and availability_policy checks getUserProfile/_accessible_files already + use elsewhere.""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: @@ -149,10 +158,6 @@ 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): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 6136a86..d02de2b 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -83,9 +83,9 @@ class Tag(models.Model): 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.""" + """Tracks the last InventoryItem id handed out to a given owner or owner_group, so ids can + be allocated sequentially and without gaps within that scope (see InventoryItem.create_for_owner). + Exactly one of owner/owner_group is set, mirroring InventoryItem's own owner/owner_group 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) @@ -117,8 +117,9 @@ class InventoryItem(SoftDeleteModel): ) 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(). + # The externally visible identifier: sequential and gapless within owner/owner_group's own + # items (see OwnerItemSequence), never the internal_id above. Always allocate through + # create_for_owner rather than InventoryItem.objects.create() directly. id = models.PositiveIntegerField(editable=False) published = models.BooleanField(default=False) name = models.CharField(max_length=255, null=True, blank=True) @@ -151,8 +152,8 @@ class InventoryItem(SoftDeleteModel): @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.""" + """The only supported way to create an InventoryItem: allocates the next id for this + owner/owner_group scope and creates the item with it, atomically.""" 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) @@ -169,27 +170,7 @@ 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 for sequential, gapless allocation - (see StorageLocation.create_for_owner).""" - owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True) - last_id = models.PositiveIntegerField(default=0) - - @classmethod - def allocate(cls, *, owner): - with transaction.atomic(): - seq, _ = cls.objects.select_for_update().get_or_create(owner=owner) - 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'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, @@ -197,23 +178,10 @@ class StorageLocation(models.Model): 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') - class Meta: - constraints = [ - models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'), - ] - def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name - @classmethod - def create_for_owner(cls, *, owner, **kwargs): - """The only supported way to create a StorageLocation: atomically allocates the next id - for this owner's scope.""" - with transaction.atomic(): - next_id = OwnerStorageLocationSequence.allocate(owner=owner) - return cls.objects.create(owner=owner, id=next_id, **kwargs) - class WorkflowInstance(models.Model): slug = models.CharField(max_length=255) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index ee9f383..10cde6e 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -1,4 +1,9 @@ -"""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.""" +"""Data helpers for building/importing a user's offline export. + +These functions deal with the toolshed models and CSV row shapes only - they know nothing +about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py +for the zip-building/parsing and the API views themselves. +""" def inventory_rows(user): @@ -71,7 +76,11 @@ def location_rows(user): 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.""" + """Generator that yields (arcname, data) for each unique file attached to the user's inventory items. + + Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original + extension (guessed from mime_type) so attachments and images remain viewable once extracted. + """ import mimetypes from toolshed.models import InventoryItem @@ -116,7 +125,11 @@ def profile_data(user): 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.""" + """Generator that yields (arcname, data) for the user's profile picture, if one is set. + + Kept separate from `inventory_files()` so the profile picture is included in the export + even for users with no inventory items or whose picture isn't attached to any item. + """ import mimetypes if not user.profile_picture: @@ -135,12 +148,21 @@ def profile_picture_files(user): 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 the given user's account-level preferences as a {key: value} dict for settings.json. + + Only account preferences (AccountPreference) 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.""" + """Fault-tolerant import of profile.json, updating the user's editable profile fields. + + Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in + `available_files`) are applied; 'username' and 'domain' are ignored since they identify the + account itself and can't be changed by an import. + """ import json try: @@ -167,7 +189,11 @@ def import_profile(user, data, available_files): 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.""" + """Fault-tolerant import of settings.json into the user's account-level preferences. + + Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a + non-object payload is skipped rather than aborting the whole import. + """ import json from authentication.models import AccountPreference @@ -191,7 +217,18 @@ def import_settings(user, data): 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.""" + """Permanently delete everything that `user_data()` exports, keeping the account itself intact. + + This removes the user's inventory items (hard delete, bypassing soft-delete), storage + locations, account preferences, the friends relation on their public identity, the profile + picture, and any File blobs (profile picture / inventory attachments) that would otherwise be + orphaned - i.e. not referenced by any other inventory item or user. Files still referenced + elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account + (and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it + doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that. + + Returns a summary dict describing what was removed. + """ from django.db import transaction from toolshed.models import InventoryItem, StorageLocation @@ -225,7 +262,15 @@ def delete_user_data(user): 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.""" + """Permanently delete the local user's account, after wiping all of its data. + + This first calls `delete_user_data()` to remove everything covered by the data export + (inventory, locations, settings, friends relation, profile picture and orphaned files), + then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote + friends/history relating to this identity remain intact - only the local account is closed. + + Returns a summary dict describing what was removed, with `account` set to True. + """ from django.db import transaction with transaction.atomic(): @@ -237,7 +282,11 @@ def delete_user_account(user): 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.""" + """Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. + + A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no + WorkflowInstance (staged file) references it anymore. Returns the number of files deleted. + """ from files.models import File deleted = 0 @@ -311,7 +360,15 @@ def get_or_create_category(path): 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.""" + """Fault-tolerant import of locations.csv into StorageLocations owned by `user`. + + Rows are read by header label. Rows missing the required 'name' column are skipped. + Optional columns ('description', 'category') are simply omitted if absent. Locations are + processed in path-depth order so a child's parent already exists by the time it's needed. + Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a + constraint violation) can't poison the surrounding transaction and silently break every + subsequent row. + """ from django.db import transaction from toolshed.models import StorageLocation @@ -339,17 +396,13 @@ def import_locations(user, data): 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) + location, _ = StorageLocation.objects.update_or_create( + owner=user, name=name, parent=parent, + defaults={ + 'description': row.get('description', '') or '', + 'category': category, + }, + ) resolved_by_path[path] = location imported += 1 except Exception as error: @@ -391,11 +444,22 @@ def import_friends(user, data): 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.""" + """Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved. + + This intentionally aborts the whole row (rather than falling back to creating a new + entity), since a handle references a *specific* entity from a *specific* origin - silently + creating a new local entity named after the raw handle string would be incorrect. + """ 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.""" + """Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance. + + Never creates anything: a handle references a specific entity from a specific origin, so + silently creating a new local entity named after the raw handle string would be incorrect. + Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists, + so the caller can skip the row and report a helpful error instead. + """ origin, rest = value.split('#', 1) if ':' in rest: found_type, name = rest.split(':', 1) @@ -411,14 +475,20 @@ def _resolve_handle(value, entity_type, model): 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.""" + """Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a + comma or a quote character, so it survives sitting inside a comma-separated "handle=value" + list unambiguously. + """ 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.""" + """Encode an item's properties as a comma-separated "handle=value" list for the 'properties' + CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it + still round-trips correctly. See `_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 @@ -427,7 +497,15 @@ def _encode_properties_cell(item_properties): 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.""" + """Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style, + wherever they appear in an entry) so a quoted value's own commas aren't mistaken for + separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal + quote, and the surrounding quotes themselves are stripped from the result. + + Only the single space after each ", " separator (as written by `_encode_properties_cell()`) + is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that + is genuinely part of a (quoted) value. + """ entries = [] current = [] in_quotes = False @@ -459,7 +537,12 @@ def _split_quoted_comma_list(raw_value): 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()`.""" + """Parse the 'properties' CSV cell into a list of (Property, value) tuples. + + The cell is a comma-separated list of "handle=value" entries; a value containing a comma or + a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives + the round trip intact. + """ raw_value = (raw_value or '').strip() if not raw_value: return [] @@ -478,7 +561,17 @@ def _parse_properties_cell(raw_value, resolve_property): 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.""" + """Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. + + `available_files` maps the 'files' column's paths to File instances successfully extracted + from the zip; references to missing files are ignored rather than failing the row. + + If a row references a fully qualified tag/property/category handle that doesn't exist + locally, the whole item is skipped (rather than creating a bogus local entity named after + the raw handle) and a helpful message is added to the returned `errors` list. Each row runs + in its own transaction savepoint so a DB-level failure on one row can't poison the + surrounding transaction and silently break every subsequent row. + """ from django.db import transaction from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 369222f..3cf9a03 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,4 +1,3 @@ -from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.serializers import OwnerSerializer, GroupOwnerSerializer @@ -151,50 +150,15 @@ class CategorySerializer(serializers.ModelSerializer): 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 not None and type(request.user) == KnownIdentity and request.user.user.exists(): - return queryset.filter(owner=request.user.user.get()) - return queryset.none() - - 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 = 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', 'parent'] - read_only_fields = ['id', 'path'] + read_only_fields = ['path'] @staticmethod def get_path(obj): @@ -202,9 +166,6 @@ 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) @@ -234,8 +195,6 @@ class InventoryItemSerializer(serializers.ModelSerializer): 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 @@ -296,7 +255,11 @@ class InventoryItemSerializer(serializers.ModelSerializer): 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. + # Hash is enough to identify a staged file (the client computes the same SHA-256 the backend + # does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this* + # session there's nothing more to say, and for a file staged elsewhere (another device/tab), + # hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files, + # no fuller FileSerializer representation is needed here. staged_files = serializers.SerializerMethodField() class Meta: diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 69c2e42..942de7e 100644 --- a/backend/toolshed/tests/fixtures.py +++ b/backend/toolshed/tests/fixtures.py @@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin): class LocationTestMixin: def prepare_locations(self): - 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']) + 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']) class WorkflowTestMixin: diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py index 202030b..fba1a42 100644 --- a/backend/toolshed/tests/test_friend.py +++ b/backend/toolshed/tests/test_friend.py @@ -85,8 +85,20 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(self.f['local_user1'].friends.count(), 1) -# Friend request/accept protocol walkthrough. See -# docs/implementation.md#friend-request-and-accept-protocol-flow. +# 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 class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase): diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py index eba0c12..dc608ba 100644 --- a/backend/toolshed/tests/test_inventory.py +++ b/backend/toolshed/tests/test_inventory.py @@ -388,8 +388,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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. + # A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity - + # see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned + # items here; it must not be treated as 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']) @@ -425,8 +426,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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. + # Parity with InventoryApiTestCase.test_post_new_item - tags/properties/category must + # attach to a group-owned item exactly the same way they do for a personal one. reply = client.post('/api/inventory_items/', self.f['local_user1'], { 'availability_policy': 'rent', 'category': 'cat2', @@ -449,7 +450,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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. + # Parity with InventoryApiTestCase.test_post_new_item_empty - clean()'s name-or-files + # validation must still apply to group-owned items. reply = client.post('/api/inventory_items/', self.f['local_user1'], { 'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id, }) @@ -469,8 +471,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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. + # Parity with InventoryApiTestCase.test_put_item - full replace, by a different member + # than the one who created it, exercising the group_items_id -> _is_authorized branch + # in perform_update for a PUT (not just PATCH). item_id = self.create_group_item().json()['id'] reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], { 'availability_policy': 'sell', @@ -536,8 +539,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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. + # Group-owned items are only ever reachable via the group's own detail page for MVP + # (see docs/design-in-progress/groups-mvp.md) - search must not surface them, same as + # the main Inventory list already doesn't. 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']) @@ -547,8 +551,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest 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).""" + """InventoryItem.id is sequential and gapless within each owner/owner_group's own items, + never reused, and allocated independently per scope - see OwnerItemSequence.""" def setUp(self): super().setUp() diff --git a/backend/toolshed/tests/test_offlinedata.py b/backend/toolshed/tests/test_offlinedata.py index ac5b55f..655cd38 100644 --- a/backend/toolshed/tests/test_offlinedata.py +++ b/backend/toolshed/tests/test_offlinedata.py @@ -123,7 +123,10 @@ class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase): 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.""" + """Properties must round-trip through export/import even when their value contains a + comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of + the 'properties' CSV cell would misinterpret as a field/entry separator. + """ def setUp(self): super().setUp() @@ -219,7 +222,10 @@ class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTes 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.""" + """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() diff --git a/deploy/dev/Dockerfile.proxy b/deploy/dev/Dockerfile.proxy index ececb93..095c80f 100644 --- a/deploy/dev/Dockerfile.proxy +++ b/deploy/dev/Dockerfile.proxy @@ -1 +1,14 @@ 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 \ diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 473ea58..30ed12a 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -72,14 +72,6 @@ services: - ./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" @@ -91,8 +83,6 @@ services: 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" diff --git a/deploy/prod/README.md b/deploy/prod/README.md index a7d47e3..c7739cc 100644 --- a/deploy/prod/README.md +++ b/deploy/prod/README.md @@ -48,27 +48,17 @@ toolshed: ``` - `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. + `ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g. + `toolshed.webdomain.tld`. Required, no default. 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 + then defaults to `toolshed_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_register_domains` in `playbook.yml`); publishing the SRV record 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. diff --git a/deploy/prod/inventory.example.yml b/deploy/prod/inventory.example.yml index 87bfc72..9979f81 100644 --- a/deploy/prod/inventory.example.yml +++ b/deploy/prod/inventory.example.yml @@ -10,21 +10,10 @@ toolshed: 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. + # log in with (user@yourtoolshed.tld). 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 + # domain above. Omit it entirely when they're the same. 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". diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml index 5e35290..aef70b6 100644 --- a/deploy/prod/playbook.yml +++ b/deploy/prod/playbook.yml @@ -15,9 +15,8 @@ # 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_repo_url, toolshed_domain, toolshed_handle_domain (optional), +# 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 @@ -54,17 +53,11 @@ # 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 }} + # see the README's DNS section). 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_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | 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 @@ -97,18 +90,8 @@ # 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 }} + {{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname)] }} # 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. @@ -260,8 +243,8 @@ 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; + ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem; client_max_body_size 128M; root {{ toolshed_dist_dir }}; @@ -347,13 +330,19 @@ # until reloaded. notify: reload nginx + # Owned by ansible_user rather than www-data up front: the frontend dist + # sync below pushes files over a plain rsync-over-ssh connection as + # ansible_user (synchronize shells out to the local rsync binary, which + # opens its own ssh session - it doesn't go through Ansible's become), + # so that account needs write access here first. "Fix ownership of + # exported frontend build" resets this to www-data (via become) right + # after the sync completes. - name: Create frontend static output directory ansible.builtin.file: path: "{{ toolshed_dist_dir }}" state: directory - owner: "www-data" - group: "www-data" - mode: "0750" + owner: "{{ ansible_user }}" + mode: "0755" - name: Create backend static output directory ansible.builtin.file: @@ -361,7 +350,7 @@ state: directory owner: www-data group: www-data - mode: "0750" + mode: "0755" - name: Write backend environment file ansible.builtin.copy: @@ -581,7 +570,7 @@ - name: Check for an existing Let's Encrypt certificate ansible.builtin.stat: - path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem" + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) @@ -644,7 +633,7 @@ - name: Re-check the certificate now that certbot has run ansible.builtin.stat: - path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem" + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) diff --git a/docs/index.md b/docs/index.md index cb46256..756dc7e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,5 @@ This is the documentation for the Toolshed project. It is a work in progress. - [Development Setup](development.md) - [About Federation](federation.md) - [Handles and Short IDs](handles-and-shortids.md) - - [Implementation Notes](implementation.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a3cb1d..7de8ccb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,30 +5,31 @@ "requires": true, "dependencies": { "@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==" }, "@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==" }, "@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", "requires": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.25.6" } }, "@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "requires": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" } }, "@esbuild/android-arm": { @@ -200,9 +201,9 @@ } }, "@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "@leichtgewicht/base64-codec": { "version": "1.0.0", @@ -249,30 +250,33 @@ "optional": true }, "@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true }, "@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "version": "4.3.19", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.19.tgz", + "integrity": "sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw==", "dev": true }, "@types/chai-subset": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", - "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true - }, - "@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", "dev": true, "requires": { - "undici-types": "~8.3.0" + "@types/chai": "*" + } + }, + "@types/node": { + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dev": true, + "requires": { + "undici-types": "~6.19.2" } }, "@vitejs/plugin-vue": { @@ -451,15 +455,15 @@ "dev": true }, "acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true }, "acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dev": true, "requires": { "acorn": "^8.11.0" @@ -475,15 +479,15 @@ } }, "ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true }, "ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true }, "anymatch": { @@ -537,9 +541,9 @@ "integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw==" }, "brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -565,16 +569,6 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true }, - "call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - } - }, "chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -662,9 +656,9 @@ } }, "confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", "dev": true }, "config-chain": { @@ -678,9 +672,9 @@ } }, "cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "requires": { "path-key": "^3.1.0", @@ -698,9 +692,9 @@ } }, "csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "data-urls": { "version": "4.0.0", @@ -723,18 +717,18 @@ } }, "debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "requires": { "ms": "^2.1.3" } }, "decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, "deep-eql": { @@ -773,17 +767,6 @@ "webidl-conversions": "^7.0.0" } }, - "dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "requires": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - } - }, "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -791,14 +774,14 @@ "dev": true }, "editorconfig": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", "dev": true, "requires": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", - "minimatch": "^9.0.1", + "minimatch": "9.0.1", "semver": "^7.5.3" } }, @@ -813,39 +796,6 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, - "es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true - }, - "es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true - }, - "es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "requires": { - "es-errors": "^1.3.0" - } - }, - "es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, "esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -906,26 +856,24 @@ } }, "foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", "dev": true, "requires": { - "cross-spawn": "^7.0.6", + "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "mime-types": "^2.1.12" } }, "fsevents": { @@ -935,50 +883,16 @@ "dev": true, "optional": true }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true - }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true }, - "get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - } - }, - "get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "requires": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - } - }, "glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "requires": { "foreground-child": "^3.1.0", @@ -987,6 +901,17 @@ "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" + }, + "dependencies": { + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } } }, "glob-parent": { @@ -998,36 +923,6 @@ "is-glob": "^4.0.1" } }, - "gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true - }, - "has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "requires": { - "has-symbols": "^1.0.3" - } - }, - "hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "requires": { - "function-bind": "^1.1.2" - } - }, "html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -1068,9 +963,9 @@ } }, "immutable": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", - "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", "dev": true }, "ini": { @@ -1143,22 +1038,22 @@ "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, "js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", + "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", "dev": true, "requires": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", - "glob": "^10.4.2", + "glob": "^10.3.3", "js-cookie": "^3.0.5", - "nopt": "^7.2.1" + "nopt": "^7.2.0" } }, "js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "dev": true }, "js-nacl": { @@ -1210,9 +1105,9 @@ "dev": true }, "lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, "loupe": { @@ -1238,12 +1133,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true - }, "md5-hex": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", @@ -1269,38 +1158,30 @@ } }, "minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", "dev": true, "requires": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^2.0.1" } }, "minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true }, "mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", + "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==", "dev": true, "requires": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - }, - "dependencies": { - "pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - } + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.1.1", + "ufo": "^1.5.3" } }, "moment": { @@ -1315,9 +1196,9 @@ "dev": true }, "nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==" + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" }, "nopt": { "version": "7.2.1", @@ -1335,9 +1216,9 @@ "dev": true }, "nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", "dev": true }, "p-limit": { @@ -1350,26 +1231,18 @@ } }, "package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "dev": true }, "parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", "dev": true, "requires": { - "entities": "^6.0.0" - }, - "dependencies": { - "entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true - } + "entities": "^4.4.0" } }, "path-key": { @@ -1406,28 +1279,20 @@ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, "pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz", + "integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==", "dev": true, "requires": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - }, - "dependencies": { - "pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - } + "confbox": "^0.1.7", + "mlly": "^1.7.1", + "pathe": "^1.1.2" } }, "popper.js": { @@ -1477,13 +1342,10 @@ "dev": true }, "psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "requires": { - "punycode": "^2.3.1" - } + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true }, "punycode": { "version": "2.3.1", @@ -1519,9 +1381,9 @@ "dev": true }, "rollup": { - "version": "3.30.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", - "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", "dev": true, "requires": { "fsevents": "~2.3.2" @@ -1560,9 +1422,9 @@ } }, "semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "shebang-command": { @@ -1604,9 +1466,9 @@ "dev": true }, "std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, "string-width": { @@ -1655,12 +1517,12 @@ } }, "strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, "requires": { - "ansi-regex": "^6.2.2" + "ansi-regex": "^6.0.1" } }, "strip-ansi-cjs": { @@ -1719,6 +1581,11 @@ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1756,15 +1623,15 @@ "dev": true }, "ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "dev": true }, "undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, "universalify": { @@ -1875,9 +1742,9 @@ } }, "vue-component-type-helpers": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", - "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz", + "integrity": "sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==", "dev": true }, "vue-multiselect": { @@ -2032,9 +1899,9 @@ } }, "ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true }, "xml-name-validator": { @@ -2050,9 +1917,9 @@ "dev": true }, "yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", "dev": true } } diff --git a/frontend/src/assets/fonts/label/Inter-Regular.woff2 b/frontend/src/assets/fonts/label/Inter-Regular.woff2 deleted file mode 100644 index d228a4a..0000000 Binary files a/frontend/src/assets/fonts/label/Inter-Regular.woff2 and /dev/null differ diff --git a/frontend/src/cameraManager.js b/frontend/src/cameraManager.js index 5420a8b..b2a3471 100644 --- a/frontend/src/cameraManager.js +++ b/frontend/src/cameraManager.js @@ -1,5 +1,7 @@ -// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device -// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling. +// Shared camera stream manager used by any component that needs webcam access +// (WebcamFileSource, Scan). Centralizing this means every consumer gets device +// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect +// handling for free instead of re-implementing it per component. class CameraManager { constructor() { this.availableCameras = []; diff --git a/frontend/src/components/AuthenticatedImage.vue b/frontend/src/components/AuthenticatedImage.vue index 75bec1f..4aae7d5 100644 --- a/frontend/src/components/AuthenticatedImage.vue +++ b/frontend/src/components/AuthenticatedImage.vue @@ -73,8 +73,9 @@ export default { return; } try { - // Cached by src alone: content is hash-addressed and immutable (see fileCache.js), - // so every instance showing the same file shares one fetch and blob. + // Cached by src alone (content is hash-addressed and immutable - see + // fileCache.js) so every AuthenticatedImage instance showing the same + // file, across the whole app, shares one fetch and one decoded blob. const url = await fileCache.get(this.src, async () => { this.servers = await this.getFriendServers({username: this.owner}); const response = await this.servers.getRaw(this.signAuth, this.src); diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index 3ffe9a7..3a489d0 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -9,9 +9,6 @@
-
- - - -
-
- - - {{ failed[t.id].short }} - - - {{ warned[t.id].map(w => w.short).join(", ") }} - -
+ :class="{'template-option-disabled': !isAvailable(t)}" + :title="isAvailable(t) ? '' : 'Not available - fill in the fields this layout needs above.'" + role="button" @click="isAvailable(t) && $emit('input', t.id)"> +
{{ t.name }} - {{ tagLabel(tag) }}
{{ t.description }}
- -
-
- mm-per-mod: {{ info.moduleMm.toFixed(2) }}, px-per-mod: {{ info.scale }} -
-
- text-mm: {{ info.fontMm.toFixed(2) }}, text-px: {{ info.fontPx.toFixed(1) }} -
-
@@ -56,7 +24,8 @@ diff --git a/frontend/src/views/Search.vue b/frontend/src/views/Search.vue index 701b3d2..dc0ab4d 100644 --- a/frontend/src/views/Search.vue +++ b/frontend/src/views/Search.vue @@ -107,9 +107,9 @@ export default { return files.filter(file => file.mime_type.startsWith("image/")); }, loadResults() { - // Search results are always personal-or-friend (inventory_items() in - // toolshed/api/inventory.py never yields group items), so owner is always a plain - // user handle - one route shape, no owner-is-me special case. + // Search results are always personal-or-friend (see inventory_items() in + // toolshed/api/inventory.py - it never yields a group's items), so the owner is + // always a plain user handle - one route shape, no owner-is-me special case. this.fetchSearchResults({query: this.query}).then((results) => { this.search_results = results.map(e => ( {...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`})) diff --git a/frontend/src/views/ShortId.vue b/frontend/src/views/ShortId.vue index 03fff50..af9f4bc 100644 --- a/frontend/src/views/ShortId.vue +++ b/frontend/src/views/ShortId.vue @@ -77,7 +77,10 @@ const EXAMPLES = [ {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ]; -// Debug-only mirror of short-id.js's bit-packing (2-bit kind tag + 4-bit chunks, see docs/handles-and-shortids.md), recomputed from `ints` for display only. +// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing +// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see +// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized +// `ints` list rather than re-parsing a token. const KIND_TAG_BITS = 2; const CHUNK_BITS = 4; const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1; @@ -156,7 +159,10 @@ export default { } }, watch: { - // expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't. + // expandedRoute reads Vuex getters derived from state.idmap (see buildExpandedRoute in + // router.js), so it re-evaluates on its own once fetchIdMap resolves below - this just + // catches that and finishes the redirect the router's own (synchronous, can't-await) + // redirect couldn't. expandedRoute(url) { if (url) { this.$router.replace(url); diff --git a/frontend/src/views/StorageLocation.vue b/frontend/src/views/StorageLocation.vue index 3d23bb7..6a5361b 100644 --- a/frontend/src/views/StorageLocation.vue +++ b/frontend/src/views/StorageLocation.vue @@ -133,10 +133,13 @@ export default { if (owner_identity_id === undefined) return null return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id}) }, - // Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's - // printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations. + // Routes to Print.vue with this location's own raw identity - userHandle + id - rather + // than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item + // (see label.js's "storage-location" LABEL_FIELD_BUILDERS entry). Locations are always + // individually owned (see StorageLocationViewSet.get_queryset), so unlike Inventory.vue's + // version this never has to fall back to "no metadata available". printLinkFor(location) { - return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}} + return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}} }, }, async mounted() { diff --git a/frontend/src/views/Swatch.vue b/frontend/src/views/Swatch.vue index 4fdd18f..9f69d01 100644 --- a/frontend/src/views/Swatch.vue +++ b/frontend/src/views/Swatch.vue @@ -3395,7 +3395,8 @@ sagittis lacus vel augue laoreet rutrum faucibus."> - +
@@ -3506,7 +3507,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3611,7 +3613,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3698,7 +3701,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3965,7 +3969,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4219,7 +4224,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4337,7 +4343,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4436,7 +4443,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4757,7 +4765,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
diff --git a/frontend/src/views/WorkflowDetail.vue b/frontend/src/views/WorkflowDetail.vue index 8ac7f24..d5491bb 100644 --- a/frontend/src/views/WorkflowDetail.vue +++ b/frontend/src/views/WorkflowDetail.vue @@ -260,7 +260,9 @@ export default { }, workflowComponent() { - // The returned component decides what to render itself, based on the `step` prop it receives. + // Return the single component implementing the whole workflow, if any, + // using the workflow component registry. The component itself decides + // what to render based on the `step` prop it receives. const workflowType = this.workflowInstance?.slug; return workflowType ? getWorkflowComponent(workflowType) : null; }, diff --git a/frontend/src/views/Workflows.vue b/frontend/src/views/Workflows.vue index b6d6054..fb9de2c 100644 --- a/frontend/src/views/Workflows.vue +++ b/frontend/src/views/Workflows.vue @@ -231,6 +231,8 @@ export default { const newWorkflow = await this.createWorkflow(workflowData); + // Immediately navigate to the workflow detail view + // Get the first step from the workflow definition const firstStep = workflow.stepDefinitions?.[0]?.step || 1; this.$router.push({ name: 'workflow-detail', @@ -249,6 +251,8 @@ export default { }, async viewWorkflowDetails(workflow) { console.log('Viewing details for workflow:', workflow); + // Navigate to the workflow detail view + // Use the workflow's current step if available, otherwise use the first step const currentStep = workflow.current_step || workflow.payload?.current_step || getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step || diff --git a/frontend/src/workflows.js b/frontend/src/workflows.js index 9a3f436..2164420 100644 --- a/frontend/src/workflows.js +++ b/frontend/src/workflows.js @@ -1,4 +1,22 @@ -// Workflow catalog: single source of truth built from each component's `meta`. See docs/implementation.md#workflow-catalog. +/** + * Workflow Catalog + * + * Single source of truth for every workflow type known to the frontend: + * what it's called, what category/description/icons it has, how many steps + * it has and what they're called, what its initial payload looks like, and + * which Vue component renders it. + * + * Each workflow has a fully co-located component + metadata as a static + * `meta` option on the component (`Component.meta`, right next to + * `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - this + * file simply imports those components and reads `.meta` off of them to + * build the catalog below. + * + * This replaces the previous design of a parallel `BaseWorkflow` class + * hierarchy (metadata) plus a separate per-step `ComponentRegistry.js` + * (components) - both concerns now live in one flat array with each + * component responsible for its own metadata and UI implementation. + */ import FotoFirstBulkImportWorkflow from '@/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue'; import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue'; import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue'; @@ -7,6 +25,9 @@ import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/Mainten import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue'; import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue'; +/** + * Workflows with a fully co-located component + metadata. + */ const workflows = [ {...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow}, {...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow}, @@ -17,27 +38,56 @@ const workflows = [ {...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow}, ]; +/** + * Get every workflow in the catalog. + * @returns {Array} + */ export function getAllWorkflows() { return workflows; } +/** + * Get a single workflow definition by id. + * @param {string} id + * @returns {Object|undefined} + */ export function getWorkflow(slug) { return workflows.find(workflow => workflow.slug === slug); } +/** + * Get the Vue component implementing a workflow's UI, if any. + * @param {string} id + * @returns {Object|null} + */ export function getWorkflowComponent(id) { return getWorkflow(id)?.component || null; } +/** + * Get all workflows belonging to a category. + * @param {string} category + * @returns {Array} + */ export function getWorkflowsByCategory(category) { return workflows.filter(workflow => workflow.category === category); } +/** + * Get all unique categories present in the catalog. + * @returns {Array} + */ export function getWorkflowCategories() { return [...new Set(workflows.map(workflow => workflow.category))]; } -// Builds the payload sent to the backend to start a new instance of this workflow. +/** + * Build the payload sent to the backend to start a new instance of a + * workflow, merging the common `workflow_config` metadata block with the + * workflow's own initial payload fields. + * @param {Object} workflow - A workflow definition, e.g. from getWorkflow() + * @returns {Object} + */ export function buildWorkflowApiPayload(workflow) { const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {}; return { @@ -52,7 +102,11 @@ export function buildWorkflowApiPayload(workflow) { }; } -// Payload is an opaque string to the backend; frontend serializes/deserializes it. See docs/implementation.md#workflow-payload-is-an-opaque-string. +/** + * The backend stores WorkflowInstance.payload as an opaque string - it never + * parses or understands it as JSON. The frontend is fully responsible for + * serializing it before sending and deserializing it after receiving. + */ export function serializeWorkflowPayload(workflow) { console.log(workflow); if (!workflow || !('payload' in workflow)) return workflow;