stash
This commit is contained in:
parent
3b494dfa37
commit
0f51f6e33f
14 changed files with 534 additions and 356 deletions
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue