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
+202
View File
@@ -0,0 +1,202 @@
import { useQuery } from "@tanstack/react-query";
import { Dices, Download, ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom";
import { formatBytes } from "@/lib/format";
import type { RandomItem } from "@/lib/types";
const RATING_FILTERS = [
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
const RATING_PILL: Record<string, string> = {
s: "bg-ctp-green text-ctp-crust",
q: "bg-ctp-peach text-ctp-crust",
e: "bg-ctp-red text-ctp-crust",
};
export default function RandomPage() {
const [ratings, setRatings] = useState<string[]>([]);
const query = useQuery({
queryKey: ["random", ratings.join(",")],
queryFn: () => {
const params = new URLSearchParams();
if (ratings.length) params.set("rating", ratings.join(","));
const suffix = params.toString();
return api<RandomItem>(`/api/random/${suffix ? `?${suffix}` : ""}`);
},
staleTime: 0,
gcTime: 0,
retry: 0,
});
const refetch = query.refetch;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
if (event.key.toLowerCase() !== "r") return;
event.preventDefault();
void refetch();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [refetch]);
function toggleRating(value: string) {
setRatings((current) =>
current.includes(value)
? current.filter((rating) => rating !== value)
: [...current, value],
);
}
const item = query.data;
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Random</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
A random image from the library. Space out the fun — press{" "}
<span className="font-mono text-ctp-subtext0">R</span> or roll
again.
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{RATING_FILTERS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleRating(option.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
ratings.includes(option.value)
? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{option.label}
</button>
))}
<span className="ml-1 text-[11px] text-ctp-overlay0">
{ratings.length ? "" : "any rating"}
</span>
</div>
</header>
{query.isPending ? (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
) : query.isError ? (
<EmptyState
title="Nothing to roll"
description={
<>
No image matches those ratings.{" "}
<Link to="/" className="text-ctp-blue hover:underline">
Browse the library
</Link>{" "}
to see what is in there.
</>
}
/>
) : item ? (
<section className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
<div className="relative flex max-h-[70vh] items-center justify-center bg-ctp-mantle">
<img
src={item.url}
alt={item.filename}
className="max-h-[70vh] w-full object-contain"
/>
{item.rating ? (
<span
className={cn(
"absolute left-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
RATING_PILL[item.rating] ?? "bg-ctp-surface1 text-ctp-text",
)}
>
{item.rating}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-ctp-surface0 px-4 py-3">
<Link
to={`/detail/${item.j_id}`}
className="font-mono text-xs text-ctp-blue hover:underline"
>
{item.j_id}
</Link>
<span
className="truncate font-mono text-xs text-ctp-subtext0"
title={item.filename}
>
{item.filename}
</span>
<span className="font-mono text-xs text-ctp-overlay0">
{item.extension.toUpperCase()} · {formatBytes(item.size)}
</span>
{item.e621_post_id ? (
<a
href={`https://e621.net/posts/${item.e621_post_id}`}
target="_blank"
rel="noreferrer"
className="font-mono text-xs text-ctp-overlay0 hover:underline"
>
e621 #{item.e621_post_id} ↗
</a>
) : null}
<div className="ml-auto flex items-center gap-2">
<a
href={item.url}
className={linkButtonClass}
title="Open the file in a new tab"
>
<ExternalLink className="h-4 w-4" /> Open
</a>
<a href={item.download_url} className={linkButtonClass}>
<Download className="h-4 w-4" /> Download
</a>
<Button onClick={() => void refetch()} disabled={query.isFetching}>
<Dices className="h-4 w-4" />
{query.isFetching ? "Rolling…" : "Another one"}
</Button>
</div>
</div>
</section>
) : null}
<p className="text-xs leading-relaxed text-ctp-overlay0">
Shell greetings: <span className="font-mono">/api/random/?fastfetch=1</span>{" "}
(or any request with a Fastfetch User-Agent) answers with png/jpg/gif
and a signed link your terminal can load directly. The same endpoint
lives at <span className="font-mono">/random</span> for scripts.
</p>
</div>
);
}