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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user