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