From 7deb6084b6cbffa0d3facfe109cb8a348a3822e5 Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Thu, 17 Sep 2026 11:17:04 -0500
Subject: [PATCH] Fix staged upload previews and allow WebP
- Staged files are now served through a signed URL (Django signing, 24h)
so / tags can load previews without an Authorization
header; the file endpoint accepts header auth or a valid signature,
rejects tampered signatures, and still scopes access to the owner
- Serializer responses now carry the request context so URLs are signed
per user
- Add .webp to the allowed extensions (backend + upload hint)
---
backend/apps/library/serializers.py | 16 +++++++-
backend/apps/library/services.py | 12 +++++-
backend/apps/library/uploads.py | 43 ++++++++++++++++++---
frontend/src/features/upload/UploadPage.tsx | 2 +-
4 files changed, 64 insertions(+), 9 deletions(-)
diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py
index c8f2e75..e966e91 100644
--- a/backend/apps/library/serializers.py
+++ b/backend/apps/library/serializers.py
@@ -1,9 +1,10 @@
import os
+from django.core import signing
from rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload
-from .services import VIDEO_EXTENSIONS
+from .services import UPLOAD_FILE_SALT, VIDEO_EXTENSIONS
class MediaLocationSerializer(serializers.ModelSerializer):
@@ -125,4 +126,15 @@ class TempUploadSerializer(serializers.ModelSerializer):
return f"J-{obj.library_item_id}" if obj.library_item_id else None
def get_file_url(self, obj):
- return f"/api/uploads/{obj.id}/file/" if obj.file else None
+ """Signed URL so / tags can fetch the staged file."""
+ if not obj.file:
+ return None
+ request = self.context.get("request")
+ user = getattr(request, "user", None)
+ if user is None or not getattr(user, "is_authenticated", False):
+ return None
+ signature = signing.dumps(
+ {"temp": str(obj.id), "user": user.id},
+ salt=UPLOAD_FILE_SALT,
+ )
+ return f"/api/uploads/{obj.id}/file/?sig={signature}"
diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py
index 5785b9c..635ff37 100644
--- a/backend/apps/library/services.py
+++ b/backend/apps/library/services.py
@@ -13,8 +13,18 @@ from django.utils.text import get_valid_filename
from .models import MediaItem, MediaLocation
-ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".apng", ".mp4", ".webm"}
+ALLOWED_EXTENSIONS = {
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".apng",
+ ".webp",
+ ".mp4",
+ ".webm",
+}
VIDEO_EXTENSIONS = {".mp4", ".webm"}
+UPLOAD_FILE_SALT = "j621.upload-file"
CHUNK_SIZE = 1024 * 1024
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
diff --git a/backend/apps/library/uploads.py b/backend/apps/library/uploads.py
index 38a54cf..cd53eef 100644
--- a/backend/apps/library/uploads.py
+++ b/backend/apps/library/uploads.py
@@ -14,10 +14,13 @@ import shutil
from pathlib import Path
from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core import signing
from django.http import Http404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
+from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from . import services
@@ -116,12 +119,42 @@ class TempUploadViewSet(
temp.file.delete(save=False)
temp.save()
return Response(
- TempUploadSerializer(temp).data, status=status.HTTP_201_CREATED
+ self.get_serializer(temp).data, status=status.HTTP_201_CREATED
)
- @action(detail=True, methods=["get"])
+ @action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
def file(self, request, pk=None):
- temp = self.get_object()
+ """Serve the staged file; accepts a signed URL for media tags."""
+ user = request.user if request.user.is_authenticated else None
+ if user is None:
+ signature = request.query_params.get("sig")
+ if signature:
+ try:
+ payload = signing.loads(
+ signature,
+ salt=services.UPLOAD_FILE_SALT,
+ max_age=86400,
+ )
+ except signing.BadSignature:
+ payload = None
+ if payload and str(payload.get("temp")) == str(pk):
+ user = (
+ get_user_model()
+ .objects.filter(pk=payload.get("user"))
+ .first()
+ )
+ if user is None or not user.can_upload:
+ return Response(
+ {"detail": "Authentication required."},
+ status=status.HTTP_401_UNAUTHORIZED,
+ )
+
+ temp = TempUpload.objects.filter(pk=pk).first()
+ is_owner = temp is not None and temp.user_id == user.id
+ if temp is None or not (
+ is_owner or user.is_staff or user.is_superuser
+ ):
+ raise Http404
if not temp.file:
raise Http404
return services.serve_file(request, temp.file.path)
@@ -139,7 +172,7 @@ class TempUploadViewSet(
if temp.status == TempUpload.STATUS_PENDING and temp.iqdb_data:
temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save(update_fields=["iqdb_data", "status", "updated_at"])
- return Response(TempUploadSerializer(temp).data)
+ return Response(self.get_serializer(temp).data)
@action(detail=True, methods=["post"])
def resolve(self, request, pk=None):
@@ -204,7 +237,7 @@ class TempUploadViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
temp.refresh_from_db()
- return Response(TempUploadSerializer(temp).data)
+ return Response(self.get_serializer(temp).data)
def perform_destroy(self, instance):
if instance.file:
diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx
index 26a8f2e..d483ea8 100644
--- a/frontend/src/features/upload/UploadPage.tsx
+++ b/frontend/src/features/upload/UploadPage.tsx
@@ -724,7 +724,7 @@ export default function UploadPage() {
Drag & drop files here
- or click to select — PNG, JPG, GIF, APNG, MP4, WEBM
+ or click to select — PNG, JPG, GIF, APNG, WEBP, MP4, WEBM