Upload board: dismiss all, real previews for indexed records

- 'dismiss all' clears every indexed record at once
- Indexed cards show the actual file preview: completed records now get a
  signed library media URL (raw for images, thumbnail for videos) so
  <img>/<video> tags can load it, including items hidden from guests
- Media raw/thumbnail endpoints accept the signature for anonymous
  requests and fall back to the normal guest-filtered path otherwise
- Guest blacklist keeps a persistent Redis mirror: an expired TTL or an
  unreachable e621 keeps the last successful list instead of falling
  back to the small local list
This commit is contained in:
2026-09-17 11:29:56 -05:00
parent b71ec729e0
commit 1086beb974
6 changed files with 117 additions and 19 deletions
+15 -2
View File
@@ -20,6 +20,7 @@ from django.core.cache import cache
logger = logging.getLogger(__name__)
CACHE_KEY = "j621.guest_blacklist"
MIRROR_KEY = "j621.guest_blacklist.mirror"
def parse_blacklist_patterns(lines):
@@ -37,10 +38,18 @@ def parse_blacklist_patterns(lines):
def cached_guest_blacklist():
"""Raw mirrored blacklist lines. Never hits the network."""
"""Raw mirrored blacklist lines. Never hits the network.
Prefers the TTL'd entry, falls back to the persistent mirror (last
successful e621 fetch) and only then to the local fallback list, so an
expired TTL never degrades filtering.
"""
stored = cache.get(CACHE_KEY)
if stored is not None:
return list(stored)
mirror = cache.get(MIRROR_KEY)
if mirror is not None:
return list(mirror)
return list(settings.GUEST_BLACKLIST_FALLBACK)
@@ -101,7 +110,11 @@ def refresh_guest_blacklist():
lines = fetch_e621_default_blacklist()
used_fallback = lines is None
if used_fallback:
lines = list(settings.GUEST_BLACKLIST_FALLBACK)
# Keep the last successful mirror (or the local list) rather than
# degrading guest filtering when e621 is unreachable.
lines = cache.get(MIRROR_KEY) or list(settings.GUEST_BLACKLIST_FALLBACK)
else:
cache.set(MIRROR_KEY, lines, timeout=None)
cache.set(CACHE_KEY, lines, settings.GUEST_BLACKLIST_TTL)
patterns = parse_blacklist_patterns(lines)
+35 -4
View File
@@ -1,10 +1,11 @@
import os
from pathlib import Path
from django.core import signing
from rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload
from .services import UPLOAD_FILE_SALT, VIDEO_EXTENSIONS
from .services import MEDIA_FILE_SALT, UPLOAD_FILE_SALT, VIDEO_EXTENSIONS
class MediaLocationSerializer(serializers.ModelSerializer):
@@ -99,6 +100,7 @@ class TempUploadSerializer(serializers.ModelSerializer):
temp_id = serializers.UUIDField(source="id", read_only=True)
library_j_id = serializers.SerializerMethodField()
file_url = serializers.SerializerMethodField()
preview_url = serializers.SerializerMethodField()
class Meta:
model = TempUpload
@@ -117,11 +119,19 @@ class TempUploadSerializer(serializers.ModelSerializer):
"iqdb_data",
"library_j_id",
"file_url",
"preview_url",
"created_at",
"updated_at",
]
read_only_fields = fields
def _request_user(self):
request = self.context.get("request")
user = getattr(request, "user", None)
if user is None or not getattr(user, "is_authenticated", False):
return None
return user
def get_library_j_id(self, obj):
return f"J-{obj.library_item_id}" if obj.library_item_id else None
@@ -129,12 +139,33 @@ class TempUploadSerializer(serializers.ModelSerializer):
"""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):
user = self._request_user()
if user is None:
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}"
def get_preview_url(self, obj):
"""A URL an <img>/<video> tag can load: staged file or library item."""
user = self._request_user()
if user is None:
return None
if obj.file:
return self.get_file_url(obj)
item = obj.library_item
if item is None:
return None
location = item.locations.first()
is_video = (
location is not None
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
)
action = "thumbnail" if is_video else "raw"
signature = signing.dumps(
{"item": item.id, "user": user.id, "action": action},
salt=MEDIA_FILE_SALT,
)
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
+1
View File
@@ -25,6 +25,7 @@ ALLOWED_EXTENSIONS = {
}
VIDEO_EXTENSIONS = {".mp4", ".webm"}
UPLOAD_FILE_SALT = "j621.upload-file"
MEDIA_FILE_SALT = "j621.media-file"
CHUNK_SIZE = 1024 * 1024
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
+26 -2
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from urllib.parse import urlparse
from django.conf import settings
from django.core import signing
from django.db.models import Min
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -67,9 +68,32 @@ class MediaItemViewSet(
self.check_object_permissions(self.request, obj)
return obj
def _signed_media_item(self, request, action_name):
"""Signed media URLs let <img>/<video> tags bypass guest filtering."""
if request.user.is_authenticated:
return None
signature = request.query_params.get("sig")
if not signature:
return None
try:
payload = signing.loads(
signature, salt=services.MEDIA_FILE_SALT, max_age=86400
)
except signing.BadSignature:
return None
if payload.get("action") != action_name:
return None
return MediaItem.objects.filter(pk=payload.get("item")).first()
def _media_object(self, request, action_name):
item = self._signed_media_item(request, action_name)
if item is not None:
return item
return self.get_object()
@action(detail=True, methods=["get"])
def raw(self, request, pk=None):
item = self.get_object()
item = self._media_object(request, "raw")
location = item.locations.first()
if location is None:
return Response(
@@ -82,7 +106,7 @@ class MediaItemViewSet(
@action(detail=True, methods=["get"])
def thumbnail(self, request, pk=None):
item = self.get_object()
item = self._media_object(request, "thumbnail")
location = item.locations.first()
if location is None:
return Response(