Fix staged upload previews and allow WebP

- Staged files are now served through a signed URL (Django signing, 24h)
  so <img>/<video> 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)
This commit is contained in:
2026-09-17 11:17:04 -05:00
parent d0e2901c92
commit 7deb6084b6
4 changed files with 64 additions and 9 deletions
+14 -2
View File
@@ -1,9 +1,10 @@
import os import os
from django.core import signing
from rest_framework import serializers from rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload from .models import MediaItem, MediaLocation, TempUpload
from .services import VIDEO_EXTENSIONS from .services import UPLOAD_FILE_SALT, VIDEO_EXTENSIONS
class MediaLocationSerializer(serializers.ModelSerializer): 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 return f"J-{obj.library_item_id}" if obj.library_item_id else None
def get_file_url(self, obj): def get_file_url(self, obj):
return f"/api/uploads/{obj.id}/file/" if obj.file else None """Signed URL so <img>/<video> 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}"
+11 -1
View File
@@ -13,8 +13,18 @@ from django.utils.text import get_valid_filename
from .models import MediaItem, MediaLocation 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"} VIDEO_EXTENSIONS = {".mp4", ".webm"}
UPLOAD_FILE_SALT = "j621.upload-file"
CHUNK_SIZE = 1024 * 1024 CHUNK_SIZE = 1024 * 1024
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$") RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
+38 -5
View File
@@ -14,10 +14,13 @@ import shutil
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import signing
from django.http import Http404 from django.http import Http404
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from . import services from . import services
@@ -116,12 +119,42 @@ class TempUploadViewSet(
temp.file.delete(save=False) temp.file.delete(save=False)
temp.save() temp.save()
return Response( 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): 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: if not temp.file:
raise Http404 raise Http404
return services.serve_file(request, temp.file.path) return services.serve_file(request, temp.file.path)
@@ -139,7 +172,7 @@ class TempUploadViewSet(
if temp.status == TempUpload.STATUS_PENDING and temp.iqdb_data: if temp.status == TempUpload.STATUS_PENDING and temp.iqdb_data:
temp.status = TempUpload.STATUS_VISUAL_MATCH temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save(update_fields=["iqdb_data", "status", "updated_at"]) 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"]) @action(detail=True, methods=["post"])
def resolve(self, request, pk=None): def resolve(self, request, pk=None):
@@ -204,7 +237,7 @@ class TempUploadViewSet(
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
temp.refresh_from_db() temp.refresh_from_db()
return Response(TempUploadSerializer(temp).data) return Response(self.get_serializer(temp).data)
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.file: if instance.file:
+1 -1
View File
@@ -724,7 +724,7 @@ export default function UploadPage() {
Drag &amp; drop files here Drag &amp; drop files here
</p> </p>
<p className="text-xs text-ctp-overlay0"> <p className="text-xs text-ctp-overlay0">
or click to select — PNG, JPG, GIF, APNG, MP4, WEBM or click to select — PNG, JPG, GIF, APNG, WEBP, MP4, WEBM
</p> </p>
<input <input
ref={inputRef} ref={inputRef}