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