Add a Random image endpoint and SPA page (with fastfetch mode)

Backend: GET /api/random/ (aliases /random and /random/) returns a random
library image with:
- rating=s,q,e filtering (comma separated, default any);
- fastfetch mode (?fastfetch=1 or any User-Agent containing "fastfetch")
  that only considers png/jpg/gif - what terminal viewers can show;
- JSON with j_id, filename, extension, rating, size, e621 id plus absolute
  url/download_url/thumbnail_url. Authenticated callers get signed URLs so
  fastfetch and image viewers can load them without headers; guests get
  unsigned URLs and never receive hidden_from_guests items.

Tests: apps/library/tests/test_random.py (8 tests) covering the response
contract, guest signatures, image-only default, the fastfetch format
restriction (flag and User-Agent), rating filters, guest visibility and the
short alias.

Frontend: /random page with rating pills, R to roll, Open/Download and a
library link, plus navigation and command palette entries; needs a backend,
hidden in local mode.

nginx: /random negotiates on Accept so browsers keep getting the SPA while
scripts get the JSON (verified with the proxy and frontend containers).

Also fixes a regression from the SSRF change: the guest download proxy
still referenced the removed 'parsed' variable on its success path, so
every proxied download would have 500'd. Redirect hops are now covered by
tests with a mocked requests.get.
This commit is contained in:
2026-09-18 13:06:36 -05:00
parent f25526782c
commit f8667c1037
14 changed files with 573 additions and 5 deletions
+94 -1
View File
@@ -520,6 +520,99 @@ class MatchTaskViewSet(
return Response({"success": True})
class RandomItemView(APIView):
"""A random library image, optionally filtered by rating.
Two kinds of clients use this:
* the SPA's Random page, which renders the returned URL, and
* shell greeting scripts (fish_greeting) that fetch the URL with
fastfetch in a terminal.
Fastfetch mode — ``?fastfetch=1`` or a User-Agent containing "fastfetch"
— only considers png/jpg/gif files, because that is what those terminals
display. Responses always carry a signed absolute URL (minted for the
requesting user) so image viewers can load it without auth headers;
guests get unsigned URLs for guest-visible items only.
"""
permission_classes = [AllowAny]
FASTFETCH_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif"}
def get(self, request):
fastfetch = request.query_params.get("fastfetch", "").lower() in {
"1",
"true",
"yes",
} or "fastfetch" in (request.META.get("HTTP_USER_AGENT") or "").lower()
extensions = (
self.FASTFETCH_EXTENSIONS
if fastfetch
else services.IMAGE_EXTENSIONS
)
queryset = MediaItem.objects.prefetch_related("locations")
if not request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
ratings = [
value
for value in request.query_params.get("rating", "").split(",")
if value in {"s", "q", "e"}
]
if ratings:
queryset = queryset.filter(rating__in=ratings)
# Any copy with an allowed extension qualifies. ORDER BY RAND() is
# fine for a personal library (same trade-off as the duplicates page).
suffixes = "|".join(extension.lstrip(".") for extension in sorted(extensions))
item = (
queryset.filter(locations__rel_path__iregex=rf"\.({suffixes})$")
.distinct()
.order_by("?")
.first()
)
if item is None:
return Response(
{"detail": "No image matches those filters."},
status=status.HTTP_404_NOT_FOUND,
)
location = next(
(
candidate
for candidate in item.locations.all()
if Path(candidate.rel_path).suffix.lower() in extensions
),
item.locations.first(),
)
url = services.signed_media_url(item, request.user, "raw", request=request)
return Response(
{
"j_id": f"J-{item.id}",
"md5": item.md5,
"filename": Path(location.rel_path).name if location else item.md5,
"extension": (
Path(location.rel_path).suffix.lower().lstrip(".")
if location
else ""
),
"kind": "image",
"rating": item.rating or "",
"size": item.size,
"e621_post_id": item.e621_post_id,
"url": url,
"download_url": f"{url}{'&' if '?' in url else '?'}download=1",
"thumbnail_url": services.signed_media_url(
item, request.user, "thumbnail", request=request
),
"fastfetch": fastfetch,
}
)
class ClientDownloadView(APIView):
"""Stream an e621 file straight to the browser (no library write)."""
@@ -562,7 +655,7 @@ class ClientDownloadView(APIView):
"Content-Type", "application/octet-stream"
)
name = get_valid_filename(
filename or Path(parsed.path).name or "download"
filename or Path(urlparse(url).path).name or "download"
)
def stream():