diff --git a/backend/authentication/api.py b/backend/authentication/api.py index 1e3f09c..599fea5 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -18,9 +18,8 @@ from hostadmin.models import Domain router = routers.SimpleRouter() -# 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. +# Schema for account-level preferences a client may store server-side (see AccountPreference); +# device-level preferences stay in the browser's local storage instead. PREFERENCE_DEFINITIONS = [ { 'key': 'ui.compact_mode', @@ -102,9 +101,8 @@ 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 usable by the - account owner on their own home server - see getUserProfile for viewing another (friend) - user's public profile.""" + """Get or update the authenticated local user's own account info; only the account owner may + call this on their own home server (see getUserProfile for viewing a friend's public profile).""" user = request.user if request.method == 'PATCH': old_file = user.profile_picture @@ -147,9 +145,9 @@ def getUserInfo(request): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def getUserProfile(request, handle): - """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).""" + """Get another local user's public profile by handle, e.g. so a friend can look up an avatar; + caller must be a friend of that user (or the user itself, signing with their own known + identity rather than local credentials).""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: @@ -213,11 +211,9 @@ 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 a {key: value} dict. PUT accepts a {key: value} - dict of one or more preferences to set/overwrite; unspecified keys are left untouched. - """ + """Get or bulk-upsert the authenticated user's account-level preferences: GET returns the + current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified + keys untouched.""" if request.method == 'PUT': if not isinstance(request.data, dict): return Response({'detail': 'Expected an object of key/value pairs.'}, status=400) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index e120ce8..a19e759 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -114,11 +114,8 @@ class ToolshedUser(AbstractUser): class AccountPreference(models.Model): - """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. - """ + """A single account-level (server-synced, cross-device) preference as a key/value pair; + device-level preferences are intentionally *not* stored here.""" user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences') key = models.CharField(max_length=255) value = models.JSONField() @@ -165,9 +162,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 (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).""" + """A pending invite tracked on the group's own home backend, checked when the invitee's accept + request arrives (mirror: GroupInviteIncoming on the invitee's backend; see + docs/design-in-progress/groups-mvp.md).""" secret = models.CharField(max_length=255) group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites') invitee_username = models.CharField(max_length=255) diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index 2be90d3..1d2d247 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -81,12 +81,8 @@ 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 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.""" + """Self-certifying verifier for the group invite/accept dance. See + docs/implementation.md#group-invite-and-accept-self-certifying-verification.""" try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) except ValueError: @@ -143,10 +139,8 @@ 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')) - # 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. + # Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead + # of treating the request as authenticated with an empty user. if identity is None: return None return identity, None diff --git a/backend/backend/asgi.py b/backend/backend/asgi.py index f4518a1..c6fe45e 100644 --- a/backend/backend/asgi.py +++ b/backend/backend/asgi.py @@ -1,12 +1,3 @@ -""" -ASGI config for backend project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ -""" - import os from django.core.asgi import get_asgi_application diff --git a/backend/backend/settings.py b/backend/backend/settings.py index e56e4ab..db7893c 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -1,14 +1,3 @@ -""" -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 @@ -19,13 +8,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent def _git_commit(): - # 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). + # Docker dev bind-mounts the real .git dir at /git (see docker-compose.yml); bare-metal dev + # finds it by walking up from BASE_DIR instead. Prod has no .git at all, so both fail and we + # fall back to the GIT_COMMIT build-arg/env var (see Dockerfile.backend/playbook.yml). cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git'] try: return subprocess.check_output( @@ -36,9 +21,6 @@ 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') @@ -127,9 +109,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'backend.wsgi.application' -# Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -139,9 +118,6 @@ 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', @@ -157,9 +133,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -# Internationalization -# https://docs.djangoproject.com/en/4.1/topics/i18n/ - LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' @@ -168,26 +141,18 @@ 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/' -# In prod, nginx (running as www-data) reads these files directly - see +# Pinned explicitly (rather than left to the backend process's ambient umask) so group-read +# is guaranteed for nginx/www-data regardless of how the container is started - see # SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`. -# 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 495aac1..105a175 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -1,18 +1,3 @@ -"""backend URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin from django.urls import path, include from drf_yasg import openapi diff --git a/backend/backend/wsgi.py b/backend/backend/wsgi.py index c4aa324..21d383b 100644 --- a/backend/backend/wsgi.py +++ b/backend/backend/wsgi.py @@ -1,12 +1,3 @@ -""" -WSGI config for backend project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ -""" - import os from django.core.wsgi import get_wsgi_application diff --git a/backend/configure.py b/backend/configure.py index cb04c8c..dde345c 100755 --- a/backend/configure.py +++ b/backend/configure.py @@ -32,11 +32,8 @@ def yesno(prompt, default=False): def configure(): - # 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. + # Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod + # container configured via --env-file) can still print them for the operator to apply manually. tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS'] unwritable = False diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index 7c5e2ff..fe8bdc7 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -24,9 +24,8 @@ THUMBNAIL_SIZES = (32, 64, 256) def _accessible_files(request): - # 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. + # Shared by media_urls and thumbnail_urls: a file is visible if the requester is + # friends-or-self with whatever references it (item, profile picture), or it's their own staged photo. return File.objects.filter( Q(connected_items__owner__in=request.user.friends_or_self()) | Q(profile_picture_users__in=request.user.friends_or_self()) | @@ -35,8 +34,7 @@ def _accessible_files(request): def _cache_headers(etag): - # 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. + # Content is hash-addressed and can never change under a given URL, so it's cacheable forever. return { 'ETag': etag, 'Cache-Control': 'max-age=31536000, private, immutable', @@ -49,23 +47,16 @@ def _cache_headers(etag): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def media_urls(request, hash_path): - # 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. + # CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's + # /redirect_media/ block must set it instead. # - # Looked up by the derived storage path (not 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. + # Looked up by the derived storage path, not the raw hash, to match FileSerializer.name + # (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase). try: file = _accessible_files(request).get(file=hash_path) - # 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. + # Must run before this check, else a bare hash + If-None-Match would let anyone probe + # file existence for files they can't see. if request.META.get('HTTP_IF_NONE_MATCH') == file.hash: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) @@ -79,9 +70,7 @@ def media_urls(request, hash_path): **cache_headers, }) else: - # 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. + # Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend. with file.file.open('rb') as fh: content = fh.read() return HttpResponse(status=status.HTTP_200_OK, @@ -94,9 +83,7 @@ def media_urls(request, hash_path): def _thumbnail_rel_path(file_hash, size): - # 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. + # Mirrors hash_upload()'s sharding under thumbnails//, reachable via the same nginx alias as originals. return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6], file_hash[6:] + '.jpg') @@ -116,22 +103,15 @@ 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 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. + # Read/write via default_storage, not a hand-rolled path, to work with both real-disk + # and in-memory test storage. rel_path = _thumbnail_rel_path(file.hash, size) if not default_storage.exists(rel_path): - # Thumbnails are always re-encoded as JPEG regardless of the original format - - # smaller and simpler than preserving e.g. PNG transparency at this scale. + # Always re-encoded as JPEG regardless of original format - simpler than preserving transparency at this scale. with file.file.open('rb') as fh: image = Image.open(fh) image.thumbnail((size, size)) - # Flatten through RGBA before dropping to RGB - 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. + # Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels. rgba = image.convert('RGBA') flattened = Image.new('RGB', rgba.size, (255, 255, 255)) flattened.paste(rgba, mask=rgba.getchannel('A')) diff --git a/backend/files/models.py b/backend/files/models.py index bff9121..a55f794 100644 --- a/backend/files/models.py +++ b/backend/files/models.py @@ -43,15 +43,7 @@ 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(): - # 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. + # Clears a stale orphan already at this hash's canonical path before saving. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path. expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '') if default_storage.exists(expected_path): default_storage.delete(expected_path) diff --git a/backend/files/tests.py b/backend/files/tests.py index 05ecb38..8c160e9 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -113,12 +113,7 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase): self.assertEqual(countdir(DefaultStorage(), ''), 3) def test_file_upload_reclaims_stale_orphan_at_canonical_path(self): - # 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. + # Regression test for a stale orphan at the canonical hash path. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path. expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}" default_storage.save(expected_path, ContentFile(self.f['test_content4'])) self.assertTrue(default_storage.exists(expected_path)) @@ -231,11 +226,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To self.prepare_properties() self.prepare_inventory() - # 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. + # Each test uses a distinct seeded image (own hash/cache path) since InMemoryStorage + # isn't reset between test methods, so a shared image risks one test's cached thumbnail leaking into another's assertions. seed = zlib.crc32(self._testMethodName.encode()) % 256 buffer = io.BytesIO() Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG') @@ -254,8 +246,7 @@ 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): - # Documents the fixed size allow-list this test suite exercises against - update both - # if files/media_urls.py's THUMBNAIL_SIZES ever changes. + # Fixed size allow-list this suite exercises - update both if media_urls.py's THUMBNAIL_SIZES changes. self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256)) @override_settings(SERVE_X_ACCEL_REDIRECT=False) @@ -274,10 +265,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To @override_settings(SERVE_X_ACCEL_REDIRECT=False) def test_thumbnail_flattens_transparency_instead_of_going_black(self): - # 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. + # Regression test for an 'LA' source with zeroed transparent-region luminance. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels. half_transparent = Image.new('LA', (200, 200)) pixels = half_transparent.load() for x in range(200): @@ -310,8 +298,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To with default_storage.open(rel_path, 'rb') as f: cached_bytes = f.read() - # Overwrite the cached file with a marker so a correct implementation must serve this - # exact content back rather than regenerating it from the original. + # Overwrites the cache with a marker so a correct implementation must serve it back, not regenerate. default_storage.delete(rel_path) default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER')) @@ -333,8 +320,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To self.assertEqual(reply.status_code, 403) def test_thumbnail_not_friend(self): - # local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the - # denied case needs a stranger to that friendship instead. + # local_user1/local_user2 are friends here (see prepare_inventory), so the denied case needs a stranger instead. reply = client.get(self._thumb_url(64), self.f['ext_user1']) self.assertEqual(reply.status_code, 404) self.assertFalse(default_storage.exists(self._thumb_rel_path(64))) diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index 9b3b8d0..03b51d9 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -50,10 +50,7 @@ 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: - # 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. + # Attaches an already-staged file by hash instead of re-uploading it. See docs/implementation.md#staged-files-are-identified-by-hash-alone. if not request.user.user.exists(): return Response(status=status.HTTP_404_NOT_FOUND) try: @@ -74,10 +71,7 @@ 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 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. + # Hash alone is enough to discover what another session/device already staged. See docs/implementation.md#staged-files-are-identified-by-hash-alone. return Response(list(workflow.staged_files.values_list('hash', flat=True))) except WorkflowInstance.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index c0c119c..833c00e 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -35,26 +35,22 @@ 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 - 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. + # Detail routes address an item by its owner-scoped id, not the internal row id. See + # docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids. lookup_field = 'id' lookup_url_kwarg = 'pk' def get_queryset(self): - # 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(). + # A pure group-member KnownIdentity may have no local ToolshedUser account; only + # personal items require .user.exists(). See + # docs/implementation.md#group-member-identities-without-local-accounts. if type(self.request.user) != KnownIdentity: return InventoryItem.objects.none() identity = self.request.user group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all()) if self.action != 'list': - # retrieve/update/destroy: 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. + # retrieve/update/destroy: any item the caller may act on, own or group. See + # docs/implementation.md#inventory-queryset-scope-by-action. if identity.user.exists(): return InventoryItem.objects.filter(owner=identity.user.get()) | group_items return group_items @@ -127,13 +123,8 @@ 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 (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.""" + """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.""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: @@ -158,6 +149,10 @@ 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 d02de2b..6136a86 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 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.""" + """Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential, + gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is + set, mirroring InventoryItem's own split.""" owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+') owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+') last_id = models.PositiveIntegerField(default=0) @@ -117,9 +117,8 @@ class InventoryItem(SoftDeleteModel): ) internal_id = models.AutoField(primary_key=True) - # 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. + # Externally visible id, sequential/gapless within owner/owner_group's own items (see + # OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create(). id = models.PositiveIntegerField(editable=False) published = models.BooleanField(default=False) name = models.CharField(max_length=255, null=True, blank=True) @@ -152,8 +151,8 @@ class InventoryItem(SoftDeleteModel): @classmethod def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs): - """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.""" + """The only supported way to create an InventoryItem: atomically allocates the next id + for this owner/owner_group scope.""" with transaction.atomic(): next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group) return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs) @@ -170,7 +169,27 @@ 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, @@ -178,10 +197,23 @@ 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 10cde6e..ee9f383 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -1,9 +1,4 @@ -"""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. -""" +"""Data helpers for building/importing a user's offline export; deal with toolshed models and CSV row shapes only, and know nothing about the zip container format or request auth - see toolshed/api/offlinedata.py for that.""" def inventory_rows(user): @@ -76,11 +71,7 @@ def location_rows(user): def inventory_files(user): - """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. - """ + """Yields (arcname, data) for each unique file attached to the user's inventory items, deduplicated by hash. See docs/implementation.md#file-naming-convention-in-exports for the naming scheme.""" import mimetypes from toolshed.models import InventoryItem @@ -125,11 +116,7 @@ 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 profile picture is included in the export - 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 picture is included even for users with no inventory items or whose picture isn't attached to any item.""" import mimetypes if not user.profile_picture: @@ -148,21 +135,12 @@ 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 (AccountPreference) 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 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. - - 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. - """ + """Fault-tolerant import of profile.json, updating the user's editable profile fields. See docs/implementation.md#profile-import-semantics for which fields are applied and why.""" import json try: @@ -189,11 +167,7 @@ def import_profile(user, data, available_files): def import_settings(user, data): - """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. - """ + """Fault-tolerant import of settings.json; each top-level key/value pair is upserted as an AccountPreference, and unreadable data or a non-object payload is skipped rather than aborting the whole import.""" import json from authentication.models import AccountPreference @@ -217,18 +191,7 @@ def import_settings(user, data): def delete_user_data(user): - """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. - """ + """Permanently delete everything that `user_data()` exports, keeping the account itself intact. Returns a summary dict describing what was removed. See docs/implementation.md#account-data-deletion for exactly what's removed and why the account itself survives.""" from django.db import transaction from toolshed.models import InventoryItem, StorageLocation @@ -262,15 +225,7 @@ def delete_user_data(user): def delete_user_account(user): - """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. - """ + """Permanently delete the local user's account, after wiping all of its data via `delete_user_data()`. Returns a summary dict with `account` set to True. See docs/implementation.md#account-data-deletion for why the underlying KnownIdentity is kept.""" from django.db import transaction with transaction.atomic(): @@ -282,11 +237,7 @@ 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. - - 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. - """ + """Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. Returns the number of files deleted. See docs/implementation.md#account-data-deletion for the orphan definition.""" from files.models import File deleted = 0 @@ -360,15 +311,7 @@ def get_or_create_category(path): def import_locations(user, data): - """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. - """ + """Fault-tolerant import of locations.csv into StorageLocations owned by `user`. See docs/implementation.md#location-import-ordering-and-savepoints for row ordering and error-isolation rules.""" from django.db import transaction from toolshed.models import StorageLocation @@ -396,13 +339,17 @@ def import_locations(user, data): if category_path: category = get_or_create_category(category_path) - location, _ = StorageLocation.objects.update_or_create( - owner=user, name=name, parent=parent, - defaults={ - 'description': row.get('description', '') or '', - 'category': category, - }, - ) + defaults = { + 'description': row.get('description', '') or '', + 'category': category, + } + try: + location = StorageLocation.objects.get(owner=user, name=name, parent=parent) + for field, value in defaults.items(): + setattr(location, field, value) + location.save(update_fields=list(defaults.keys())) + except StorageLocation.DoesNotExist: + location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults) resolved_by_path[path] = location imported += 1 except Exception as error: @@ -444,22 +391,11 @@ def import_friends(user, data): class _HandleNotFound(Exception): - """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. - """ + """Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved, aborting the row rather than creating a new entity. See docs/implementation.md#handle-resolution-semantics.""" def _resolve_handle(value, entity_type, model): - """Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance. - - Never creates 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. - """ + """Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance; never creates one. See docs/implementation.md#handle-resolution-semantics for the rationale.""" origin, rest = value.split('#', 1) if ':' in rest: found_type, name = rest.split(':', 1) @@ -475,20 +411,14 @@ def _resolve_handle(value, entity_type, model): def _quote_value_if_needed(value): - """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. - """ + """Wrap `value` in double quotes (CSV-style, doubling embedded quotes) if it contains a comma or quote character. See docs/implementation.md#properties-csv-encoding.""" if any(ch in value for ch in ',"'): return '"' + value.replace('"', '""') + '"' return value def _encode_properties_cell(item_properties): - """Encode an item's properties as a comma-separated "handle=value" list for the 'properties' - CSV cell, 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. - """ + """Encode an item's properties as a comma-separated "handle=value" list for the 'properties' CSV cell. See docs/implementation.md#properties-csv-encoding and `_parse_properties_cell()` for the reader side.""" entries = [ f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}" for ip in item_properties @@ -497,15 +427,7 @@ 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, - 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. - """ + """Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style) so a quoted value's own commas aren't mistaken for separators. See docs/implementation.md#properties-csv-encoding for the quoting/whitespace rules this implements.""" entries = [] current = [] in_quotes = False @@ -537,12 +459,7 @@ 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. - - 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. - """ + """Parse the 'properties' CSV cell into a list of (Property, value) tuples. See docs/implementation.md#properties-csv-encoding for how the cell is encoded by `_encode_properties_cell()`.""" raw_value = (raw_value or '').strip() if not raw_value: return [] @@ -561,17 +478,7 @@ 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`. - - `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. - """ + """Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. See docs/implementation.md#inventory-import-semantics for file/handle resolution and error-isolation rules.""" from django.db import transaction from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 3cf9a03..369222f 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,3 +1,4 @@ +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 @@ -150,15 +151,50 @@ 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 = ['path'] + read_only_fields = ['id', 'path'] @staticmethod def get_path(obj): @@ -166,6 +202,9 @@ 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) @@ -195,6 +234,8 @@ 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 @@ -255,11 +296,7 @@ class InventoryItemSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer): owner = serializers.StringRelatedField(read_only=True) - # 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. + # Only the hash is needed to identify a staged file, unlike InventoryItemSerializer.files. See docs/implementation.md#staged-files-are-identified-by-hash-alone. staged_files = serializers.SerializerMethodField() class Meta: diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 942de7e..69c2e42 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.objects.create(name='loc1', owner=self.f['local_user1']) - self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'], - category=self.f['cat1']) - self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) - self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], - category=self.f['cat1']) + self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1']) + self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'], + category=self.f['cat1']) + self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) + self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], + category=self.f['cat1']) class WorkflowTestMixin: diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py index fba1a42..202030b 100644 --- a/backend/toolshed/tests/test_friend.py +++ b/backend/toolshed/tests/test_friend.py @@ -85,20 +85,8 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(self.f['local_user1'].friends.count(), 1) -# what ~should~ happen: -# 1. user x@A sends a friend request to user y@B -# 1.1. x@A's client sends a POST request to A/api/friendrequests/ with body {from: x@A, to: y@B} -# 1.2. A's backend creates a FriendRequestOutgoing object, containing x@A's identity and y@B's name -# 1.3. x@A's client sends a POST request to B/api/friendrequests/ with body -# {from: x@A, to: y@B, public_key: x@A's public key} -# 1.4. B's backend creates a FriendRequestIncoming object, containing y@B's and x@A's identities -# 2. user y@B accepts the friend request -# 2.1. y@B's client sends a POST request to A/api/friendsrequests/ with body -# {from: x@A, to: y@B, public_key: y@B's public key} -# 2.2. A's backend matches the data to the FriendRequestOutgoing object, deletes both and creates a Friend object, -# containing x@A's and y@B's identities -# 2.3. y@B's client sends a POST request to B/api/friends/ containing the id of the FriendRequestIncoming object -# 2.4. B's backend creates a Friend object, using the identities from the FriendRequestIncoming object +# Friend request/accept protocol walkthrough. See +# docs/implementation.md#friend-request-and-accept-protocol-flow. class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase): diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py index dc608ba..eba0c12 100644 --- a/backend/toolshed/tests/test_inventory.py +++ b/backend/toolshed/tests/test_inventory.py @@ -388,9 +388,8 @@ 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 (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. + # A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned + # items - not unauthorized just because .user.exists() is False. self.f['group1'].members.add(self.f['ext_user1'].public_identity) item_id = self.create_group_item().json()['id'] reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1']) @@ -426,8 +425,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 must - # attach to a group-owned item exactly the same way they do for a personal one. + # Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach + # to a group-owned item the same way as a personal one. reply = client.post('/api/inventory_items/', self.f['local_user1'], { 'availability_policy': 'rent', 'category': 'cat2', @@ -450,8 +449,7 @@ 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 must still apply to group-owned items. + # Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies. reply = client.post('/api/inventory_items/', self.f['local_user1'], { 'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id, }) @@ -471,9 +469,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest self.assertEqual(len(reply.json()), 0) def test_put_group_item(self): - # 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). + # Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than + # the creator, to exercise the _is_authorized branch in perform_update for PUT too. item_id = self.create_group_item().json()['id'] reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], { 'availability_policy': 'sell', @@ -539,9 +536,8 @@ 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 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. + # Group-owned items are reachable only via the group's own detail page for MVP (see + # docs/design-in-progress/groups-mvp.md) - search must not surface them either. self.create_group_item(name='searchable-drill') InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal') reply = client.get('/api/search/?query=searchable', self.f['local_user1']) @@ -551,8 +547,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase): - """InventoryItem.id is sequential and gapless within each owner/owner_group's own items, - never reused, and allocated independently per scope - see OwnerItemSequence.""" + """InventoryItem.id is sequential, gapless, and never reused within each owner/owner_group's + own items, allocated independently per scope (see OwnerItemSequence).""" def setUp(self): super().setUp() diff --git a/backend/toolshed/tests/test_offlinedata.py b/backend/toolshed/tests/test_offlinedata.py index 655cd38..ac5b55f 100644 --- a/backend/toolshed/tests/test_offlinedata.py +++ b/backend/toolshed/tests/test_offlinedata.py @@ -123,10 +123,7 @@ 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 an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of - the 'properties' CSV cell would misinterpret as a field/entry separator. - """ + """Properties must round-trip through export/import even when their value contains a comma or '=' sign, which a naive "handle=value, handle2=value2" encoding would otherwise misinterpret as a separator.""" def setUp(self): super().setUp() @@ -222,10 +219,7 @@ 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 095c80f..ececb93 100644 --- a/deploy/dev/Dockerfile.proxy +++ b/deploy/dev/Dockerfile.proxy @@ -1,14 +1 @@ FROM nginx:bookworm - -# snakeoil for localhost - -RUN apt-get update && \ - apt-get install -y openssl && \ - openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \ - openssl rsa -passin pass:x -in server.pass.key -out server.key && \ - rm server.pass.key && \ - openssl req -new -key server.key -out server.csr \ - -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \ - openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\ - mv server.crt /etc/nginx/nginx.crt && \ - mv server.key /etc/nginx/nginx.key \ diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 30ed12a..473ea58 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -72,6 +72,14 @@ 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" @@ -83,6 +91,8 @@ 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 c7739cc..a7d47e3 100644 --- a/deploy/prod/README.md +++ b/deploy/prod/README.md @@ -48,17 +48,27 @@ toolshed: ``` - `toolshed_domain` — the **web domain**: the nginx `server_name`, Django - `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. + `ALLOWED_HOSTS`, and the hostname(s) you'll point a TLS cert at — e.g. + `toolshed.webdomain.tld`. Required, no default. May be a single domain (as + above) or a list, e.g. to also answer on a `www.` alias: + ```yaml + toolshed_domain: + - toolshed.webdomain.tld + - www.toolshed.webdomain.tld + ``` + The Let's Encrypt certificate covers all of them, named on disk after + whichever one is listed first. This is not necessarily the same as the + **handle domain** your users log in with (the part after `@` in + `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate. - `toolshed_handle_domain` — the **handle domain**, only needed when it's different from `toolshed_domain`. Omit it when the two are the same (it - then defaults to `toolshed_domain`). 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`). Like `toolshed_domain`, it may be a + single domain or a list, e.g. if this deployment accepts registrations for + more than one handle domain. It doesn't affect nginx/Django at all (they + only ever accept `toolshed_domain` as the `Host` header) — it's used solely to populate the `/local/domains` registration fixture (see - `toolshed_register_domains` in `playbook.yml`); publishing the SRV record is a - separate, manual DNS step either way. + `toolshed_register_domains` in `playbook.yml`); publishing the SRV record for + each handle domain is a separate, manual DNS step either way. - `toolshed_repo_url` — the git remote the playbook checks out and builds from. Required, no default. - `toolshed_version` — the branch, tag or commit to check out and build. diff --git a/deploy/prod/inventory.example.yml b/deploy/prod/inventory.example.yml index 9979f81..87bfc72 100644 --- a/deploy/prod/inventory.example.yml +++ b/deploy/prod/inventory.example.yml @@ -10,10 +10,21 @@ 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). + # log in with (user@yourtoolshed.tld). May be a single domain (as + # here) or a list, e.g. to also answer on a "www." alias: + # toolshed_domain: + # - toolshed.webdomain.tld + # - www.toolshed.webdomain.tld + # The Let's Encrypt certificate is requested for all of them, named + # after whichever one is listed first. toolshed_domain: toolshed.webdomain.tld # Optional - only needed if the handle domain differs from the web - # domain above. Omit it entirely when they're the same. + # domain above. Omit it entirely when they're the same. Like + # toolshed_domain, this may be a single domain or a list, e.g. if this + # deployment accepts registrations for more than one handle domain: + # toolshed_handle_domain: + # - yourtoolshed.tld + # - alt.yourtoolshed.tld toolshed_handle_domain: yourtoolshed.tld toolshed_repo_url: git@example.com:your-org/toolshed.git # Optional - branch, tag or commit to deploy. Defaults to "stable". diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml index aef70b6..5e35290 100644 --- a/deploy/prod/playbook.yml +++ b/deploy/prod/playbook.yml @@ -15,8 +15,9 @@ # 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), -# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and +# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional, +# either may be a single domain or a list of domains), toolshed_version +# (optional, defaults to "stable"), behind_tls_proxy and # toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are # per-deployment and must be set as host_vars in inventory.yml (copy # inventory.example.yml) rather than here or via -e, so that each host in @@ -53,11 +54,17 @@ # 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). 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 }}" + # see the README's DNS section). toolshed_handle_domain may be a single + # domain or a list; when unset it falls back to toolshed_domain (whole + # list, if that's a list too). Served as a static /local/domains fixture + # that the frontend's registration/pairing forms fetch to populate their + # domain dropdown (frontend/src/views/Register.vue, Pairing.vue) - + # without it that dropdown is just empty. + toolshed_handle_domain_or_default: "{{ toolshed_handle_domain | default(toolshed_domain) }}" + toolshed_register_domains: >- + {{ ([toolshed_handle_domain_or_default] + if toolshed_handle_domain_or_default is string + else toolshed_handle_domain_or_default) | unique }} # DoH resolvers the frontend falls back to for SRV lookups when it has # no cached preference yet, served as a static /local/dns fixture. These # match the frontend's own hardcoded fallback (frontend/src/dns.js), so @@ -90,8 +97,18 @@ # 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 | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname)] }} + {{ ([toolshed_domain_checked] + if toolshed_domain_checked is string + else toolshed_domain_checked) | unique }} # Generated once per host on the controller and reused on every # subsequent run against that host, keyed by inventory_hostname so # separate deployments never end up sharing a Django SECRET_KEY. @@ -243,8 +260,8 @@ listen [::]:443 ssl; server_name {{ toolshed_hostnames | join(' ') }}; - ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem; + ssl_certificate /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/privkey.pem; client_max_body_size 128M; root {{ toolshed_dist_dir }}; @@ -330,19 +347,13 @@ # 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: "{{ ansible_user }}" - mode: "0755" + owner: "www-data" + group: "www-data" + mode: "0750" - name: Create backend static output directory ansible.builtin.file: @@ -350,7 +361,7 @@ state: directory owner: www-data group: www-data - mode: "0755" + mode: "0750" - name: Write backend environment file ansible.builtin.copy: @@ -570,7 +581,7 @@ - name: Check for an existing Let's Encrypt certificate ansible.builtin.stat: - path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) @@ -633,7 +644,7 @@ - name: Re-check the certificate now that certbot has run ansible.builtin.stat: - path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) diff --git a/docs/index.md b/docs/index.md index 756dc7e..cb46256 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,5 +10,6 @@ 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 7de8ccb..9a3cb1d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,31 +5,30 @@ "requires": true, "dependencies": { "@babel/helper-string-parser": { - "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==" + "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==" }, "@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==" + "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==" }, "@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "requires": { - "@babel/types": "^7.25.6" + "@babel/types": "^7.29.8" } }, "@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "requires": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" } }, "@esbuild/android-arm": { @@ -201,9 +200,9 @@ } }, "@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "@leichtgewicht/base64-codec": { "version": "1.0.0", @@ -250,33 +249,30 @@ "optional": true }, "@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, "@types/chai": { - "version": "4.3.19", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.19.tgz", - "integrity": "sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw==", + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "dev": true }, "@types/chai-subset": { - "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": { - "@types/chai": "*" - } + "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": "22.5.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "requires": { - "undici-types": "~6.19.2" + "undici-types": "~8.3.0" } }, "@vitejs/plugin-vue": { @@ -455,15 +451,15 @@ "dev": true }, "acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true }, "acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "requires": { "acorn": "^8.11.0" @@ -479,15 +475,15 @@ } }, "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true }, "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true }, "anymatch": { @@ -541,9 +537,9 @@ "integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw==" }, "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "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==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -569,6 +565,16 @@ "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", @@ -656,9 +662,9 @@ } }, "confbox": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", - "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, "config-chain": { @@ -672,9 +678,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0", @@ -692,9 +698,9 @@ } }, "csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "data-urls": { "version": "4.0.0", @@ -717,18 +723,18 @@ } }, "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "requires": { "ms": "^2.1.3" } }, "decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, "deep-eql": { @@ -767,6 +773,17 @@ "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", @@ -774,14 +791,14 @@ "dev": true }, "editorconfig": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", - "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "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" } }, @@ -796,6 +813,39 @@ "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", @@ -856,24 +906,26 @@ } }, "foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "requires": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "fsevents": { @@ -883,16 +935,50 @@ "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.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "requires": { "foreground-child": "^3.1.0", @@ -901,17 +987,6 @@ "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": { @@ -923,6 +998,36 @@ "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", @@ -963,9 +1068,9 @@ } }, "immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", "dev": true }, "ini": { @@ -1038,22 +1143,22 @@ "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, "js-beautify": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", - "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", "dev": true, "requires": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", - "glob": "^10.3.3", + "glob": "^10.4.2", "js-cookie": "^3.0.5", - "nopt": "^7.2.0" + "nopt": "^7.2.1" } }, "js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", "dev": true }, "js-nacl": { @@ -1105,9 +1210,9 @@ "dev": true }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true }, "loupe": { @@ -1133,6 +1238,12 @@ "@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", @@ -1158,30 +1269,38 @@ } }, "minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" } }, "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true }, "mlly": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", - "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "requires": { - "acorn": "^8.11.3", - "pathe": "^1.1.2", - "pkg-types": "^1.1.1", - "ufo": "^1.5.3" + "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 + } } }, "moment": { @@ -1196,9 +1315,9 @@ "dev": true }, "nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" + "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==" }, "nopt": { "version": "7.2.1", @@ -1216,9 +1335,9 @@ "dev": true }, "nwsapi": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", - "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "dev": true }, "p-limit": { @@ -1231,18 +1350,26 @@ } }, "package-json-from-dist": { - "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==", + "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==", "dev": true }, "parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "requires": { - "entities": "^4.4.0" + "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 + } } }, "path-key": { @@ -1279,20 +1406,28 @@ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, "pkg-types": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz", - "integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==", + "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==", "dev": true, "requires": { - "confbox": "^0.1.7", - "mlly": "^1.7.1", - "pathe": "^1.1.2" + "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 + } } }, "popper.js": { @@ -1342,10 +1477,13 @@ "dev": true }, "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "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" + } }, "punycode": { "version": "2.3.1", @@ -1381,9 +1519,9 @@ "dev": true }, "rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", "dev": true, "requires": { "fsevents": "~2.3.2" @@ -1422,9 +1560,9 @@ } }, "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true }, "shebang-command": { @@ -1466,9 +1604,9 @@ "dev": true }, "std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, "string-width": { @@ -1517,12 +1655,12 @@ } }, "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "requires": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" } }, "strip-ansi-cjs": { @@ -1581,11 +1719,6 @@ "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", @@ -1623,15 +1756,15 @@ "dev": true }, "ufo": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", - "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true }, "undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true }, "universalify": { @@ -1742,9 +1875,9 @@ } }, "vue-component-type-helpers": { - "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==", + "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==", "dev": true }, "vue-multiselect": { @@ -1899,9 +2032,9 @@ } }, "ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true }, "xml-name-validator": { @@ -1917,9 +2050,9 @@ "dev": true }, "yocto-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", - "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true } } diff --git a/frontend/src/assets/fonts/label/Inter-Regular.woff2 b/frontend/src/assets/fonts/label/Inter-Regular.woff2 new file mode 100644 index 0000000..d228a4a Binary files /dev/null and b/frontend/src/assets/fonts/label/Inter-Regular.woff2 differ diff --git a/frontend/src/cameraManager.js b/frontend/src/cameraManager.js index b2a3471..5420a8b 100644 --- a/frontend/src/cameraManager.js +++ b/frontend/src/cameraManager.js @@ -1,7 +1,5 @@ -// 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. +// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device +// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling. class CameraManager { constructor() { this.availableCameras = []; diff --git a/frontend/src/components/AuthenticatedImage.vue b/frontend/src/components/AuthenticatedImage.vue index 4aae7d5..75bec1f 100644 --- a/frontend/src/components/AuthenticatedImage.vue +++ b/frontend/src/components/AuthenticatedImage.vue @@ -73,9 +73,8 @@ export default { return; } try { - // 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. + // 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. 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 3a489d0..3ffe9a7 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -9,6 +9,9 @@
+
+ + + +
- + :class="{'template-option-disabled': !isSelectable(t)}" + :title="unavailableReason(t)" + role="button" @click="isSelectable(t) && $emit('input', t.id)"> +
+ + + {{ failed[t.id].short }} + + + {{ warned[t.id].map(w => w.short).join(", ") }} + +
{{ 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) }} +
+
@@ -24,8 +56,7 @@ diff --git a/frontend/src/views/Search.vue b/frontend/src/views/Search.vue index dc0ab4d..701b3d2 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 (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. + // 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. 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 af9f4bc..03fff50 100644 --- a/frontend/src/views/ShortId.vue +++ b/frontend/src/views/ShortId.vue @@ -77,10 +77,7 @@ const EXAMPLES = [ {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ]; -// 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. +// 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. const KIND_TAG_BITS = 2; const CHUNK_BITS = 4; const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1; @@ -159,10 +156,7 @@ export default { } }, watch: { - // 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 re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard 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 6a5361b..3d23bb7 100644 --- a/frontend/src/views/StorageLocation.vue +++ b/frontend/src/views/StorageLocation.vue @@ -133,13 +133,10 @@ 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 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". + // 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. printLinkFor(location) { - return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}} + return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}} }, }, async mounted() { diff --git a/frontend/src/views/Swatch.vue b/frontend/src/views/Swatch.vue index 9f69d01..4fdd18f 100644 --- a/frontend/src/views/Swatch.vue +++ b/frontend/src/views/Swatch.vue @@ -3395,8 +3395,7 @@ sagittis lacus vel augue laoreet rutrum faucibus."> - +
@@ -3507,8 +3506,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3613,8 +3611,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3701,8 +3698,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -3969,8 +3965,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4224,8 +4219,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4343,8 +4337,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4443,8 +4436,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
@@ -4765,8 +4757,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
- +
diff --git a/frontend/src/views/WorkflowDetail.vue b/frontend/src/views/WorkflowDetail.vue index d5491bb..8ac7f24 100644 --- a/frontend/src/views/WorkflowDetail.vue +++ b/frontend/src/views/WorkflowDetail.vue @@ -260,9 +260,7 @@ export default { }, workflowComponent() { - // 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. + // The returned component decides what to render itself, 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 fb9de2c..b6d6054 100644 --- a/frontend/src/views/Workflows.vue +++ b/frontend/src/views/Workflows.vue @@ -231,8 +231,6 @@ 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', @@ -251,8 +249,6 @@ 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 2164420..9a3f436 100644 --- a/frontend/src/workflows.js +++ b/frontend/src/workflows.js @@ -1,22 +1,4 @@ -/** - * 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. - */ +// Workflow catalog: single source of truth built from each component's `meta`. See docs/implementation.md#workflow-catalog. import FotoFirstBulkImportWorkflow from '@/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue'; import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue'; import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue'; @@ -25,9 +7,6 @@ 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}, @@ -38,56 +17,27 @@ 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))]; } -/** - * 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} - */ +// Builds the payload sent to the backend to start a new instance of this workflow. export function buildWorkflowApiPayload(workflow) { const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {}; return { @@ -102,11 +52,7 @@ export function buildWorkflowApiPayload(workflow) { }; } -/** - * 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. - */ +// Payload is an opaque string to the backend; frontend serializes/deserializes it. See docs/implementation.md#workflow-payload-is-an-opaque-string. export function serializeWorkflowPayload(workflow) { console.log(workflow); if (!workflow || !('payload' in workflow)) return workflow;