This commit is contained in:
j3d1 2026-08-16 23:52:58 +02:00
parent 3b494dfa37
commit 0f51f6e33f
14 changed files with 534 additions and 356 deletions

View file

@ -132,6 +132,7 @@ def getUserInfo(request):
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \ if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \
and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0: and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0:
old_file.file.delete(save=False)
old_file.delete() old_file.delete()
return Response({ return Response({

View file

@ -1,8 +1,17 @@
import io
import os
from datetime import timedelta
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import path from django.urls import path
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.utils.http import http_date
from django.utils.timezone import now
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from PIL import Image
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -11,6 +20,29 @@ from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from files.models import File from files.models import File
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.
return File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self()) |
Q(staged_by_workflows__owner__in=request.user.user.all())
).distinct()
def _cache_headers(etag):
# Content is addressed by its own hash and can never change under a given URL, so caches
# (and the conditional-GET checks in both views below) can treat it as immutable forever.
return {
'ETag': etag,
'Cache-Control': 'max-age=31536000, private, immutable',
'Expires': http_date((now() + timedelta(days=365)).timestamp()),
}
@swagger_auto_schema(method='GET', auto_schema=None) @swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET']) @api_view(['GET'])
@ -23,30 +55,112 @@ def media_urls(request, hash_path):
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it # 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 # 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. # case has to be configured in nginx's `location /redirect_media/` block 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.
try: try:
file = File.objects.filter( file = _accessible_files(request).get(file=hash_path)
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self()) | # The access-control lookup above must happen before this check - otherwise a bare
Q(staged_by_workflows__owner__in=request.user.user.all()) # hash + If-None-Match would let anyone probe "does a file with this hash exist" for
).distinct().get( # files they can't actually see.
file=hash_path) if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
cache_headers = _cache_headers(file.hash)
if settings.SERVE_X_ACCEL_REDIRECT: if settings.SERVE_X_ACCEL_REDIRECT:
return HttpResponse(status=status.HTTP_200_OK, return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type, content_type=file.mime_type,
headers={ headers={
'X-Accel-Redirect': f'/redirect_media/{hash_path}', 'X-Accel-Redirect': f'/redirect_media/{hash_path}',
}) # TODO Expires and Cache-Control **cache_headers,
})
else: 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.
with file.file.open('rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK, return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type, content_type=file.mime_type,
content=open(file.file.path, 'rb').read()) headers=cache_headers,
content=content)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
def _thumbnail_rel_path(file_hash, size):
# Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails/<size>/`
# subtree - reachable through the same nginx `/redirect_media/` alias as originals when
# served from real disk, no separate nginx location needed.
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
file_hash[6:] + '.jpg')
@swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def thumbnail_urls(request, size, hash_path):
if size not in THUMBNAIL_SIZES:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = _accessible_files(request).get(file=hash_path)
etag = f'{file.hash}_{size}'
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
# Read/write through the default storage backend, same as File.file itself, rather
# than a hand-rolled filesystem path - correct regardless of storage backend (real
# disk in production, in-memory under the test runner) and keeps the cache in the
# same place originals live.
rel_path = _thumbnail_rel_path(file.hash, size)
if not default_storage.exists(rel_path):
# Thumbnails are always re-encoded as JPEG regardless of the original format -
# smaller and simpler than preserving e.g. PNG transparency at this scale.
with file.file.open('rb') as fh:
image = Image.open(fh)
image.thumbnail((size, size))
# Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha,
# palette-with-transparency, RGBA) store meaningless color/luminance data under
# fully transparent pixels (often zeroed out, i.e. black). Converting straight
# to RGB reveals that instead of "nothing there"; compositing onto an opaque
# background first shows what the image is actually supposed to look like.
rgba = image.convert('RGBA')
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
flattened.paste(rgba, mask=rgba.getchannel('A'))
buffer = io.BytesIO()
flattened.save(buffer, 'JPEG', quality=90)
default_storage.save(rel_path, ContentFile(buffer.getvalue()))
cache_headers = _cache_headers(etag)
if settings.SERVE_X_ACCEL_REDIRECT:
return HttpResponse(status=status.HTTP_200_OK,
content_type='image/jpeg',
headers={
'X-Accel-Redirect': f'/redirect_media/{rel_path}',
**cache_headers,
})
else:
with default_storage.open(rel_path, 'rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK,
content_type='image/jpeg',
headers=cache_headers,
content=content)
except File.DoesNotExist: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
urlpatterns = [ urlpatterns = [
path('<int:size>/<path:hash_path>/', thumbnail_urls),
path('<path:hash_path>', media_urls), path('<path:hash_path>', media_urls),
] ]

View file

@ -1,4 +1,7 @@
from types import SimpleNamespace
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models, IntegrityError from django.db import models, IntegrityError
from django.db.models import Model from django.db.models import Model
@ -40,6 +43,18 @@ class FileManager(models.Manager):
else: else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided') raise ValueError('data must be a base64 encoded string or file and hash must be provided')
if not self.filter(hash=kwargs['hash']).exists(): 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.
expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '')
if default_storage.exists(expected_path):
default_storage.delete(expected_path)
return super().create(**kwargs) return super().create(**kwargs)
else: else:
raise IntegrityError('File with this hash already exists') raise IntegrityError('File with this hash already exists')

View file

@ -1,13 +1,20 @@
import io
import os
import zlib
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import DefaultStorage from django.core.files.storage import DefaultStorage, default_storage
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.test import Client, override_settings from django.test import Client, override_settings
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
from toolshed.tests import InventoryTestMixin from toolshed.tests import InventoryTestMixin
from nacl.hash import sha256 from nacl.hash import sha256
from nacl.encoding import HexEncoder from nacl.encoding import HexEncoder
from PIL import Image
import base64 import base64
from files.media_urls import THUMBNAIL_SIZES
from files.models import File from files.models import File
anonymous_client = Client() anonymous_client = Client()
@ -105,6 +112,23 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
self.assertEqual(File.objects.count(), 3) self.assertEqual(File.objects.count(), 3)
self.assertEqual(countdir(DefaultStorage(), ''), 3) 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.
expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}"
default_storage.save(expected_path, ContentFile(self.f['test_content4']))
self.assertTrue(default_storage.exists(expected_path))
self.assertFalse(File.objects.filter(hash=self.f['hash4']).exists())
file = File.objects.create(mime_type='text/plain', data=self.f['encoded_content4'])
self.assertEqual(file.file.name, expected_path)
self.assertEqual(file.file.read(), self.f['test_content4'])
class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase): class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self): def setUp(self):
@ -120,28 +144,29 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['item2'].files.add(self.f['test_file1']) self.f['item2'].files.add(self.f['test_file1'])
# def test_file_url(self): @override_settings(SERVE_X_ACCEL_REDIRECT=True)
# reply = client.get( def test_file_url(self):
# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", reply = client.get(
# self.f['local_user1']) f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user1'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") self.assertEqual(reply.headers['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
# reply = client.get( self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", reply = client.get(
# self.f['local_user1']) f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user1'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") self.assertEqual(reply.headers['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
# reply = client.get( self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", reply = client.get(
# self.f['local_user2']) f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user2'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") self.assertEqual(reply.headers['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
def test_file_url_fail(self): def test_file_url_fail(self):
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1']) reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
@ -195,3 +220,138 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['ext_user1']) self.f['ext_user1'])
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_files()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
self.prepare_inventory()
# Each test method gets its own distinct image content (and therefore its own content
# hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within
# a run, so sharing one fixed image across methods risks one test's cached (or, in
# test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted)
# thumbnail leaking into another test's assertions.
seed = zlib.crc32(self._testMethodName.encode()) % 256
buffer = io.BytesIO()
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
image_bytes = buffer.getvalue()
self.f['image_hash'] = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
self.f['image_file'] = File.objects.create(
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
self.f['item1'].files.add(self.f['image_file'])
def _thumb_url(self, size, image_hash=None):
h = image_hash or self.f['image_hash']
return f"/media/{size}/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}/"
def _thumb_rel_path(self, size):
h = self.f['image_hash']
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
def test_thumbnail_sizes_available(self):
# Documents the fixed size allow-list this test suite exercises against - update both
# if files/media_urls.py's THUMBNAIL_SIZES ever changes.
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_generates_resized_jpeg(self):
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.headers['Content-Type'], 'image/jpeg')
generated = Image.open(io.BytesIO(reply.content))
self.assertEqual(generated.format, 'JPEG')
# Aspect-ratio-preserving fit within a 64x64 box, not a crop to exactly 64x64.
self.assertLessEqual(max(generated.size), 64)
self.assertAlmostEqual(generated.size[0] / generated.size[1], 800 / 600, places=2)
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
# Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent
# region has zeroed-out luminance underneath, as many image tools produce. Converting
# straight to RGB (dropping alpha without compositing) reveals that zeroed data - the
# whole thumbnail comes out solid black even though the visible (opaque) content isn't.
half_transparent = Image.new('LA', (200, 200))
pixels = half_transparent.load()
for x in range(200):
for y in range(200):
if x < 100:
pixels[x, y] = (0, 0) # transparent, zeroed-out luminance underneath
else:
pixels[x, y] = (255, 255) # fully opaque, bright content
buffer = io.BytesIO()
half_transparent.save(buffer, 'PNG')
image_bytes = buffer.getvalue()
image_hash = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
image_file = File.objects.create(
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
self.f['item1'].files.add(image_file)
reply = client.get(self._thumb_url(64, image_hash=image_hash), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
generated = Image.open(io.BytesIO(reply.content)).convert('L')
# The opaque (right) half must stay bright; a naive RGB conversion would blacken it too.
self.assertGreater(generated.getpixel((generated.width - 1, generated.height // 2)), 200)
self.assertNotEqual(generated.getextrema(), (0, 0))
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_served_from_cache_on_second_request(self):
client.get(self._thumb_url(64), self.f['local_user1'])
rel_path = self._thumb_rel_path(64)
with default_storage.open(rel_path, 'rb') as f:
cached_bytes = f.read()
# Overwrite the cached file with a marker so a correct implementation must serve this
# exact content back rather than regenerating it from the original.
default_storage.delete(rel_path)
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.content.endswith(b'MARKER'))
def test_thumbnail_invalid_size(self):
reply = client.get(self._thumb_url(100), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
self.assertFalse(default_storage.exists(self._thumb_rel_path(100)))
def test_thumbnail_not_found(self):
reply = client.get(self._thumb_url(64, image_hash='0' * 64), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
def test_thumbnail_anonymous(self):
reply = anonymous_client.get(self._thumb_url(64))
self.assertEqual(reply.status_code, 403)
def test_thumbnail_not_friend(self):
# local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the
# denied case needs a stranger to that friendship instead.
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
def test_thumbnail_conditional_get(self):
reply = client.get(self._thumb_url(64), self.f['local_user1'])
etag = reply.headers['ETag']
self.assertEqual(etag, f"{self.f['image_hash']}_64")
reply = client.get(self._thumb_url(64), self.f['local_user1'], HTTP_IF_NONE_MATCH=etag)
self.assertEqual(reply.status_code, 304)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_thumbnail_x_accel_redirect(self):
reply = client.get(self._thumb_url(64), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
h = self.f['image_hash']
self.assertEqual(reply.headers['X-Accel-Redirect'],
f"/redirect_media/thumbnails/64/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}.jpg")

View file

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

View file

@ -53,10 +53,10 @@ def post_item_file(request, item_id):
def get_staged_files(request, workflow_id): def get_staged_files(request, workflow_id):
try: try:
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
# Staged files are private working state the client already holds in full (name, size, # Hash alone identifies a staged file (client and server hash content the same way, and
# mime_type, base64 data) from the moment it read them off disk/camera - the only thing # bytes are fetchable from a hash-derived storage path) - useful mainly for discovering
# it can't already know is whether/under what hash the upload was persisted, so that's # what another session/device already staged on this workflow, unlike the fuller
# all this returns, unlike the fuller FileSerializer representation item_files uses. # FileSerializer representation item_files uses.
return Response(list(workflow.staged_files.values_list('hash', flat=True))) return Response(list(workflow.staged_files.values_list('hash', flat=True)))
except WorkflowInstance.DoesNotExist: except WorkflowInstance.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
@ -95,6 +95,7 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/
item.files.remove(file_id) item.files.remove(file_id)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0: and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete() file.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except InventoryItem.DoesNotExist: except InventoryItem.DoesNotExist:
@ -123,6 +124,7 @@ def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged
workflow.staged_files.remove(file) workflow.staged_files.remove(file)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0: and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete() file.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except WorkflowInstance.DoesNotExist: except WorkflowInstance.DoesNotExist:

View file

@ -126,6 +126,7 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
for file in File.objects.filter(id__in=staged_file_ids): for file in File.objects.filter(id__in=staged_file_ids):
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0: and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete() file.delete()

View file

@ -203,9 +203,11 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class WorkflowInstanceSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True) owner = serializers.StringRelatedField(read_only=True)
# The client already holds the full file (name, size, mime_type, base64 data) for anything it # Hash is enough to identify a staged file (the client computes the same SHA-256 the backend
# staged itself - hash is the only thing it can't already know, so that's all this exposes, # does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this*
# unlike InventoryItemSerializer.files which needs the fuller FileSerializer representation. # session there's nothing more to say, and for a file staged elsewhere (another device/tab),
# hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files,
# no fuller FileSerializer representation is needed here.
staged_files = serializers.SerializerMethodField() staged_files = serializers.SerializerMethodField()
class Meta: class Meta:

View file

@ -30,14 +30,20 @@ export default {
const jobs = [...files].map((file) => { const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -53,14 +53,20 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let reader = new FileReader(); let reader = new FileReader();
reader.readAsArrayBuffer(file) reader.readAsArrayBuffer(file)
reader.onloadend = () => { reader.onloadend = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later
// be used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -33,14 +33,20 @@ export default {
const jobs = [...files].map((file) => { const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -171,11 +171,17 @@ export default {
this.dataImage = undefined; this.dataImage = undefined;
this.open(); this.open();
}, },
save() { async save() {
const mimeType = this.dataImage.split(';')[0].split(':')[1]; const mimeType = this.dataImage.split(';')[0].split(':')[1];
const data = this.dataImage.split(',')[1]; const data = this.dataImage.split(',')[1];
const raw_data = atob(data); const raw_data = atob(data);
const hash = nacl.crypto_hash(raw_data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
// hashlib.sha256) so a hash computed here can later be used to identify the same File
// row server-side without a mismatch.
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
const image = { const image = {
name: hash.slice(0, 12) + ".jpg", name: hash.slice(0, 12) + ".jpg",
size: raw_data.length, size: raw_data.length,

View file

@ -3,127 +3,35 @@
<!-- Step 1: Photo Capture --> <!-- Step 1: Photo Capture -->
<div v-if="step === '1'" class="foto-first-step-1"> <div v-if="step === '1'" class="foto-first-step-1">
<div class="upload-area mb-4"> <div class="staging-area mb-4">
<div class="row"> <drag-drop-file-source @input="addStagedFiles">
<!-- Camera Capture --> <div class="card">
<div class="col-md-6 mb-3"> <div class="card-body text-center">
<div class="card h-100"> <b-icon-upload class="text-primary mb-2" style="font-size: 2.5rem;"></b-icon-upload>
<div class="card-body text-center"> <p class="text-muted small mb-3">Drag and drop photos here, or add them below</p>
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera> <div class="d-flex justify-content-center gap-2">
<h6>Camera Capture</h6> <fs-file-source @input="addStagedFiles">
<p class="text-muted small">Use your device camera to capture photos</p> <span class="btn btn-outline-success">
<button class="btn btn-primary" @click="startCamera" :disabled="loadingCamera"> <b-icon-upload class="me-1"></b-icon-upload>
<b-icon-camera class="me-1"></b-icon-camera> Upload Files
Start Camera </span>
</button>
</div>
</div>
</div>
<!-- File Upload -->
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
<h6>File Upload</h6>
<p class="text-muted small">Upload photos from your device</p>
<input
type="file"
ref="fileInput"
multiple
accept="image/*"
@change="handleFileUpload"
class="d-none"
/>
<button class="btn btn-success" @click="$refs.fileInput.click()"
:disabled="loadingCamera">
<b-icon-upload class="me-1"></b-icon-upload>
Upload Photos
</button>
</div>
</div>
</div>
<hr>
<drag-drop-file-source @input="addFiles">
<ul>
<li v-for="file in without_images(staged_files)" :key="file.id">
{{ file.name }}
</li>
</ul>
<hr>
<div style="position: relative;">
<div class="image-list">
<deletable-wrapper v-for="file in only_images(staged_files).filter(file => file.owner)"
:key="file.id"
@delete="deleteFile(file)">
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
</deletable-wrapper>
<deletable-wrapper v-for="file in only_images(staged_files).filter(file => file.data)"
:key="file.id"
@delete="deleteTempFile(file)">
<img :alt="file.name" :src="'data:' + file.mime_type + ';base64,' + file.data"
class="img-thumbnail border-info">
</deletable-wrapper>
<fs-file-source @input="addFiles">
<div class="img-thumbnail btn btn-outline-primary">
<b-icon-upload></b-icon-upload>
</div>
</fs-file-source> </fs-file-source>
<camera-file-source @input="addFiles"> <camera-file-source @input="addStagedFiles">
<div class="img-thumbnail btn btn-outline-primary"> <span class="btn btn-outline-primary">
<b-icon-camera></b-icon-camera> <b-icon-camera class="me-1"></b-icon-camera>
</div> Camera
</span>
</camera-file-source> </camera-file-source>
<webcam-file-source @input="addFiles"> <webcam-file-source @input="addStagedFiles">
<div class="img-thumbnail btn btn-outline-primary"> <span class="btn btn-outline-primary">
<b-icon-camera-video></b-icon-camera-video> <b-icon-camera-video class="me-1"></b-icon-camera-video>
</div> Webcam
</span>
</webcam-file-source> </webcam-file-source>
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
<b-icon-plus></b-icon-plus>
</label>
</div>
<input type="checkbox" id="file-dropdown" class="invisible-input">
<div class="dropdown-menu" v-if="only_images([]).length > 0">
<div class="image-list">
<span v-for="file in only_images([])" :key="file.id" @click="addExistingFiles([file])"
style="cursor: pointer;">
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
</span>
</div>
</div>
</div>
</drag-drop-file-source>
</div>
</div>
<!-- Camera Preview -->
<div v-if="showCamera" class="camera-section mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Camera Preview</h6>
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
<b-icon-x></b-icon-x>
</button>
</div>
<div class="card-body">
<div class="camera-container text-center">
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
<div>
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
<b-icon-camera class="me-1"></b-icon-camera>
Capture Photo
</button>
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop class="me-1"></b-icon-stop>
Stop Camera
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </drag-drop-file-source>
</div> </div>
<!-- Photo Gallery --> <!-- Photo Gallery -->
@ -138,7 +46,15 @@
<div class="row"> <div class="row">
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3"> <div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card"> <div class="card">
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`"> <div class="photo-thumb-wrap">
<transition name="photo-wipe">
<img v-if="photo.dataUrl && !photo.uploaded" key="local" :src="photo.dataUrl"
class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
<authenticated-image v-else key="remote" :src="thumbnailPathForHash(photo.hash)"
:owner="user" img-class="card-img-top photo-thumbnail"
:alt="`Photo ${index + 1}`"/>
</transition>
</div>
<div class="card-body p-2"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Photo {{ index + 1 }}</small> <small class="text-muted">Photo {{ index + 1 }}</small>
@ -158,7 +74,7 @@
<button <button
class="btn btn-primary" class="btn btn-primary"
@click="proceedFromStep1" @click="proceedFromStep1"
:disabled="photos.length === 0 || loadingCamera" :disabled="photos.length === 0"
> >
Next: Process Images Next: Process Images
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right> <b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
@ -353,7 +269,7 @@
<!-- Image Preview --> <!-- Image Preview -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card">
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" <img :src="currentItem.processedUrl || currentItem.dataUrl" class="card-img-top item-image"
alt="Current item"> alt="Current item">
<div class="card-body p-2"> <div class="card-body p-2">
<small class="text-muted">{{ currentItem.name }}</small> <small class="text-muted">{{ currentItem.name }}</small>
@ -534,7 +450,7 @@
<div v-for="(item, index) in completedItems" :key="index" <div v-for="(item, index) in completedItems" :key="index"
class="col-sm-6 col-md-4 col-lg-3 mb-2"> class="col-sm-6 col-md-4 col-lg-3 mb-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img :src="item.image.processedUrl || item.image.preview" <img :src="item.image.processedUrl || item.image.dataUrl"
class="completed-item-thumb me-2" alt="Item"> class="completed-item-thumb me-2" alt="Item">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-bold small">{{ item.details.name }}</div> <div class="fw-bold small">{{ item.details.name }}</div>
@ -724,7 +640,7 @@
<div v-for="(item, index) in completedItems" :key="index" <div v-for="(item, index) in completedItems" :key="index"
class="col-sm-6 col-md-4 col-lg-3 mb-3"> class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card h-100"> <div class="card h-100">
<img :src="item.image.processedUrl || item.image.preview" <img :src="item.image.processedUrl || item.image.dataUrl"
class="card-img-top item-thumb" :alt="item.details.name"> class="card-img-top item-thumb" :alt="item.details.name">
<div class="card-body p-2"> <div class="card-body p-2">
<h6 class="card-title mb-1">{{ item.details.name }}</h6> <h6 class="card-title mb-1">{{ item.details.name }}</h6>
@ -756,7 +672,7 @@
<tbody> <tbody>
<tr v-for="(item, index) in completedItems" :key="index"> <tr v-for="(item, index) in completedItems" :key="index">
<td> <td>
<img :src="item.image.processedUrl || item.image.preview" <img :src="item.image.processedUrl || item.image.dataUrl"
class="list-item-thumb" :alt="item.details.name"> class="list-item-thumb" :alt="item.details.name">
</td> </td>
<td class="fw-bold">{{ item.details.name }}</td> <td class="fw-bold">{{ item.details.name }}</td>
@ -823,7 +739,6 @@
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import DeletableWrapper from "@/components/DeletableWrapper.vue";
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue"; import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
import CameraFileSource from "@/components/inputs/CameraFileSource.vue"; import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue"; import FsFileSource from "@/components/inputs/FsFileSource.vue";
@ -846,7 +761,6 @@ export default {
], ],
getInitialPayload() { getInitialPayload() {
return { return {
photos: [],
processing_options: { processing_options: {
auto_rotate: true, auto_rotate: true,
compress: true, compress: true,
@ -859,7 +773,6 @@ export default {
components: { components: {
WebcamFileSource, WebcamFileSource,
AuthenticatedImage, AuthenticatedImage,
DeletableWrapper,
DragDropFileSource, DragDropFileSource,
CameraFileSource, CameraFileSource,
FsFileSource, FsFileSource,
@ -881,13 +794,8 @@ export default {
}, },
data() { data() {
return { return {
staged_files: [],
// Step 1: photo capture // Step 1: photo capture
loadingCamera: false,
showCamera: false,
cameraReady: false,
photos: [], photos: [],
stream: null,
// Step 2: image processing // Step 2: image processing
processing: false, processing: false,
@ -919,6 +827,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(['user']),
// Step 1/2 // Step 1/2
totalPhotos() { totalPhotos() {
return this.photos.length; return this.photos.length;
@ -972,7 +881,6 @@ export default {
this.loadFromPayload(); this.loadFromPayload();
}, },
beforeUnmount() { beforeUnmount() {
this.stopCamera();
this.processedImages.forEach(image => { this.processedImages.forEach(image => {
if (image.processedUrl && image.processedUrl.startsWith('blob:')) { if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
URL.revokeObjectURL(image.processedUrl); URL.revokeObjectURL(image.processedUrl);
@ -980,9 +888,22 @@ export default {
}); });
}, },
methods: { methods: {
...mapActions(['stageFile']), ...mapActions(['stageFile', 'unstageFile']),
loadFromPayload() { loadFromPayload() {
if (this.payload.photos) this.photos = [...this.payload.photos]; // `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
// in sync directly by stageFile()/unstageFile(), not by writing to payload) - so it's
// seeded from the prop, not from payload. Entries restored this way have no local
// bytes yet (this session never uploaded them), so `dataUrl` stays null - the gallery
// falls back to fetching a thumbnail by hash via AuthenticatedImage (see below).
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
hash,
name: null,
size: null,
mime_type: null,
dataUrl: null,
uploaded: true,
timestamp: null
}));
if (this.payload.processing_options) { if (this.payload.processing_options) {
this.processingOptions = {...this.processingOptions, ...this.payload.processing_options}; this.processingOptions = {...this.processingOptions, ...this.payload.processing_options};
} }
@ -997,92 +918,78 @@ export default {
}, },
// --- Step 1: Photo capture --- // --- Step 1: Photo capture ---
async startCamera() { thumbnailPathForHash(hash, size = 256) {
try { // files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
this.loadingCamera = true; // on first request - a gallery card only needs a small image, not the full-size
this.stream = await navigator.mediaDevices.getUserMedia({ // original. Looked up by the derived storage path, mirroring hash_upload()
video: {facingMode: 'environment'} // (files/models.py) - matches how FileSerializer.name already builds file URLs
}); // elsewhere in the app (e.g. AuthenticatedImage's `src` for item files).
this.$refs.video.srcObject = this.stream; return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
this.showCamera = true;
this.cameraReady = true;
} catch (error) {
console.error('Error accessing camera:', error);
alert('Could not access camera. Please check permissions or use file upload instead.');
} finally {
this.loadingCamera = false;
}
}, },
stopCamera() { async addStagedFiles(files) {
if (this.stream) { const new_files = files.filter(file => !this.photos.find(photo => photo.hash === file.hash));
this.stream.getTracks().forEach(track => track.stop()); if (new_files.length === 0) return;
this.stream = null;
}
this.showCamera = false;
this.cameraReady = false;
},
capturePhoto() { const staged = new_files.map(file => ({
if (!this.cameraReady) return; name: file.name,
size: file.size,
mime_type: file.mime_type,
hash: file.hash, // SHA-256, same algorithm the backend hashes File content with
data: file.data,
dataUrl: `data:${file.mime_type};base64,${file.data}`,
uploaded: false,
timestamp: new Date().toISOString()
}));
this.photos.push(...staged);
const canvas = document.createElement('canvas'); // Persist each photo server-side right away, keyed to this workflow instance, so it
const video = this.$refs.video; // survives a reload or a switch to another device. WorkflowInstance.staged_files is
canvas.width = video.videoWidth; // the durable record of this - nothing about photos needs to go into payload too.
canvas.height = video.videoHeight; await Promise.all(staged.map(async ({hash, data, mime_type}) => {
try {
const ctx = canvas.getContext('2d'); await this.stageFile({
ctx.drawImage(video, 0, 0); lifetime_id: this.workflowInstance.id,
file: {data, mime_type}
canvas.toBlob(blob => { });
const photo = { // Once persisted, the gallery can show the server-fetched thumbnail instead
file: blob, // of the local dataUrl (kept around for step 2's client-side processing).
preview: URL.createObjectURL(blob), // Re-lookup by hash rather than mutating the closed-over `photo` object -
name: `camera-photo-${Date.now()}.jpg`, // that reference predates this.photos.push() above, so it's the raw object,
timestamp: new Date().toISOString() // not the reactive proxy Vue tracks; writing to it wouldn't trigger a
}; // re-render.
this.photos.push(photo); const photo = this.photos.find(p => p.hash === hash);
this.updatePhotosPayload(); if (photo) photo.uploaded = true;
}, 'image/jpeg', 0.8); } catch (error) {
}, console.error('Failed to stage photo:', error);
this.photos = this.photos.filter(p => p.hash !== hash);
handleFileUpload(event) {
const files = Array.from(event.target.files);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const photo = {
file: file,
preview: URL.createObjectURL(file),
name: file.name,
timestamp: new Date().toISOString()
};
this.photos.push(photo);
} }
}); }));
this.updatePhotosPayload();
event.target.value = '';
}, },
removePhoto(index) { async removePhoto(index) {
URL.revokeObjectURL(this.photos[index].preview); const [photo] = this.photos.splice(index, 1);
this.photos.splice(index, 1); if (photo) {
this.updatePhotosPayload(); try {
}, await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
} catch (error) {
clearAllPhotos() { console.error('Failed to unstage photo:', error);
if (confirm('Are you sure you want to remove all photos?')) { }
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
this.photos = [];
this.updatePhotosPayload();
} }
}, },
updatePhotosPayload() { async clearAllPhotos() {
this.$emit('update', {photos: this.photos}); if (confirm('Are you sure you want to remove all photos?')) {
const removed = this.photos;
this.photos = [];
await Promise.all(removed.map(photo =>
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
.catch(error => console.error('Failed to unstage photo:', error))
));
}
}, },
proceedFromStep1() { proceedFromStep1() {
this.updatePhotosPayload();
this.$emit('next'); this.$emit('next');
}, },
@ -1144,7 +1051,7 @@ export default {
canvas.toBlob(blob => { canvas.toBlob(blob => {
const processedImage = { const processedImage = {
name: photo.name, name: photo.name,
originalSize: photo.file.size, originalSize: photo.size,
processedSize: blob.size, processedSize: blob.size,
processedUrl: URL.createObjectURL(blob), processedUrl: URL.createObjectURL(blob),
processedFile: blob, processedFile: blob,
@ -1153,7 +1060,7 @@ export default {
resolve(processedImage); resolve(processedImage);
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95); }, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
}; };
img.src = photo.preview; img.src = photo.dataUrl;
}); });
}, },
@ -1335,84 +1242,65 @@ export default {
category_breakdown: this.categoryBreakdown category_breakdown: this.categoryBreakdown
} }
}); });
},
async uploadFiles(files) {
const jobs = files.map(async file => {
return await this.stageFile({
file: file,
item_id: this.item_id
});
});
return await Promise.all(jobs);
},
addFiles(files) {
console.log("add files", files);
const new_files = files.filter(file => !this.staged_files.find(f => f.hash === file.hash));
if (new_files.length === 0) {
console.log("no new files");
return;
}
if (!this.create) {
this.uploadFiles(new_files).then((uploaded) => {
this.$emit("change", [...this.staged_files, ...uploaded]);
})
} else {
this.$emit("change", [...this.staged_files, ...new_files]);
}
},
addExistingFiles(files) {
console.log("add existing files", files);
const new_files = files.filter(file => !this.staged_files.find(f => f.id === file.id));
if (new_files.length === 0) {
console.log("no new files");
return;
}
this.$emit("change", [...this.staged_files, ...new_files]);
},
deleteFile(file) {
this.deleteItemFile({item_id: this.item_id, file_id: file.id}).then(() => {
this.$emit("change", this.staged_files.filter(f => f.id !== file.id));
});
},
deleteTempFile(file) {
this.$emit("change", this.staged_files.filter(f => f.hash !== file.hash));
},
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
without_images(files) {
return files.filter(file => !file.mime_type.startsWith("image/"));
} }
} }
} }
</script> </script>
<style scoped> <style scoped>
.camera-preview {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
}
.photo-thumbnail, .photo-thumbnail,
.item-thumb { .item-thumb {
height: 150px; height: 150px;
object-fit: cover; object-fit: cover;
} }
.upload-area .card { /* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
other during their crossfade, instead of one disappearing before the other lays out. */
.photo-thumb-wrap {
position: relative;
height: 150px;
overflow: hidden;
}
.photo-thumb-wrap .photo-thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photo-wipe-enter-active,
.photo-wipe-leave-active {
transition: clip-path 0.5s ease, opacity 0.5s ease;
}
.photo-wipe-enter-from {
clip-path: inset(0 100% 0 0);
opacity: 0.6;
}
.photo-wipe-enter-to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
.photo-wipe-leave-from {
opacity: 1;
}
.photo-wipe-leave-to {
opacity: 0;
}
.staging-area .card {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
.upload-area .card:hover { .staging-area .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
.camera-container {
position: relative;
}
.processed-thumbnail { .processed-thumbnail {
height: 120px; height: 120px;
object-fit: cover; object-fit: cover;
@ -1464,45 +1352,4 @@ export default {
border: 2px solid #28a745; border: 2px solid #28a745;
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%); background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
} }
.img-thumbnail {
width: 95px;
height: 54px;
object-fit: cover;
}
.img-thumbnail svg {
width: 100%;
height: 100%;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.invisible-input {
display: none;
}
#file-dropdown:checked ~ .dropdown-menu {
display: block;
}
.dropdown-menu:hover {
display: block;
}
#file-dropdown:checked ~ label {
color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
#file-dropdown:checked ~ label:hover {
color: var(--bs-primary);
background-color: initial;
}
</style> </style>

View file

@ -491,9 +491,20 @@ export default createStore({
async stageFile({state, dispatch, getters}, {lifetime_id, file}) { async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file) const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file)
if (data.hash) {
return data.hash
}
},
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
const servers = await dispatch('getHomeServers')
await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/')
},
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash})
if (data.name) { if (data.name) {
data.owner = state.user data.owner = state.user
//state.files.push(data) state.files.push(data)
return data return data
} }
}, },