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:
@@ -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 <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}"
|
||||
|
||||
@@ -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*)$")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -724,7 +724,7 @@ export default function UploadPage() {
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<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>
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
Reference in New Issue
Block a user