Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download
Backend: - User.role (user/uploader/staff) with can_upload; uploads and downloads gated to uploader+; owners and staff can edit their items - MediaItem.uploaded_by plus J-<id> identity (serializer, admin, scan_files --user, first superuser as default owner) - API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup return j_ids - Guest safety: mirror e621's anonymous default blacklist into Redis (parses comments, negations and wildcards), flag hidden_from_guests and filter lists, details and lookups for anonymous users - POST /api/online/downloads/ writes an e621 file into the watched folder and indexes it for the uploader - MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL driver shim, Redis cache replacing the file cache; SQLite data dumped and loaded into MariaDB Frontend: - Single /detail/:itemId route with an adaptive shell: J-<id> renders the library item, bare numbers render the e621 post - Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs - Cards expose J-IDs; library custom-data editor is read-only for non-owners - Role gating: Upload hidden/blocked for regular users, account shows the role, guest hint on the library
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Min
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import services
|
||||
from .models import MediaItem
|
||||
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
|
||||
from .serializers import MediaItemSerializer
|
||||
|
||||
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
||||
|
||||
|
||||
class MediaItemViewSet(
|
||||
@@ -23,13 +29,15 @@ class MediaItemViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
serializer_class = MediaItemSerializer
|
||||
lookup_field = "md5"
|
||||
permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly]
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = MediaItem.objects.prefetch_related("locations").annotate(
|
||||
name=Min("locations__rel_path")
|
||||
)
|
||||
if not self.request.user.is_authenticated:
|
||||
queryset = queryset.filter(hidden_from_guests=False)
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
queryset = queryset.filter(locations__rel_path__icontains=search)
|
||||
@@ -46,8 +54,22 @@ class MediaItemViewSet(
|
||||
)
|
||||
return queryset.distinct()
|
||||
|
||||
def get_object(self):
|
||||
"""Resolve J-<id>, a bare numeric id, or an MD5 fingerprint."""
|
||||
value = str(self.kwargs.get("pk", "")).strip()
|
||||
queryset = self.get_queryset()
|
||||
if MD5_RE.fullmatch(value):
|
||||
obj = get_object_or_404(queryset, md5=value.lower())
|
||||
else:
|
||||
numeric = value[2:] if value.upper().startswith("J-") else value
|
||||
if not numeric.isdigit():
|
||||
raise Http404
|
||||
obj = get_object_or_404(queryset, pk=int(numeric))
|
||||
self.check_object_permissions(self.request, obj)
|
||||
return obj
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def raw(self, request, md5=None):
|
||||
def raw(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
@@ -60,7 +82,7 @@ class MediaItemViewSet(
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def thumbnail(self, request, md5=None):
|
||||
def thumbnail(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
@@ -81,7 +103,7 @@ class MediaItemViewSet(
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
||||
def lookup(self, request):
|
||||
"""Return which of the given MD5s already exist in the library."""
|
||||
"""Report which of the given MD5s exist in the library."""
|
||||
md5s = request.data.get("md5s")
|
||||
if not isinstance(md5s, list):
|
||||
return Response(
|
||||
@@ -89,11 +111,19 @@ class MediaItemViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
wanted = {str(value).strip().lower()[:32] for value in md5s if value}
|
||||
found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True)
|
||||
return Response({"found": sorted(found)})
|
||||
queryset = MediaItem.objects.filter(md5__in=wanted)
|
||||
if not request.user.is_authenticated:
|
||||
queryset = queryset.filter(hidden_from_guests=False)
|
||||
rows = list(queryset.values_list("md5", "id"))
|
||||
return Response(
|
||||
{
|
||||
"found": sorted(md5 for md5, _ in rows),
|
||||
"j_ids": {md5: f"J-{item_id}" for md5, item_id in rows},
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def neighbors(self, request, md5=None):
|
||||
def neighbors(self, request, pk=None):
|
||||
"""Previous/next items in the ordered list, for keyboard navigation."""
|
||||
item = self.get_object()
|
||||
ordering = request.query_params.get("ordering", "-created_at").strip()
|
||||
@@ -120,6 +150,7 @@ class MediaItemViewSet(
|
||||
def brief(obj):
|
||||
location = obj.locations.first()
|
||||
return {
|
||||
"j_id": f"J-{obj.id}",
|
||||
"md5": obj.md5,
|
||||
"filename": Path(location.rel_path).name if location else obj.md5,
|
||||
}
|
||||
@@ -135,7 +166,7 @@ class MediaItemViewSet(
|
||||
|
||||
|
||||
class UploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [CanUpload]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
@@ -150,7 +181,7 @@ class UploadView(APIView):
|
||||
for chunk in upload.chunks():
|
||||
output.write(chunk)
|
||||
try:
|
||||
item, _, _ = services.index_file(destination, folder)
|
||||
item, created_item, _ = services.index_file(destination, folder)
|
||||
except Exception as exc: # noqa: BLE001 - report indexing failures
|
||||
destination.unlink(missing_ok=True)
|
||||
return Response(
|
||||
@@ -159,6 +190,9 @@ class UploadView(APIView):
|
||||
)
|
||||
|
||||
update_fields = []
|
||||
if created_item and item.uploaded_by_id is None:
|
||||
item.uploaded_by = request.user
|
||||
update_fields.append("uploaded_by")
|
||||
rating = request.data.get("rating") or ""
|
||||
if rating in {"s", "q", "e"} and item.rating != rating:
|
||||
item.rating = rating
|
||||
@@ -178,3 +212,42 @@ class UploadView(APIView):
|
||||
MediaItemSerializer(item, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class DownloadToLibraryView(APIView):
|
||||
"""Fetch an e621 file into the watched folder and index it."""
|
||||
|
||||
permission_classes = [CanUpload]
|
||||
|
||||
def post(self, request):
|
||||
url = str(request.data.get("url") or "").strip()
|
||||
post_id = request.data.get("post_id")
|
||||
filename = str(request.data.get("filename") or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return Response(
|
||||
{"detail": "A valid file URL is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
folder = Path(settings.WATCHED_FOLDER)
|
||||
name = (
|
||||
filename
|
||||
or Path(urlparse(url).path).name
|
||||
or f"post-{post_id or 'download'}"
|
||||
)
|
||||
destination = services.unique_destination(folder, name)
|
||||
try:
|
||||
services.download_file(url, destination)
|
||||
item, _, _ = services.index_file(destination, folder)
|
||||
except Exception as exc: # noqa: BLE001 - report download failures
|
||||
destination.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{"detail": f"Download failed: {exc}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if item.uploaded_by_id is None:
|
||||
item.uploaded_by = request.user
|
||||
item.save(update_fields=["uploaded_by", "updated_at"])
|
||||
return Response(
|
||||
MediaItemSerializer(item, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user