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
+39 -4
View File
@@ -5,7 +5,7 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D
## Structure ## Structure
``` ```
backend/ Django 6 + DRF API (SQLite in dev, MariaDB in production) backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
frontend/ Vite + React + TypeScript SPA frontend/ Vite + React + TypeScript SPA
``` ```
@@ -33,9 +33,44 @@ npm run dev # http://localhost:5173, proxies /api to
### Production ### Production
Not wired up yet — planned: Nginx serving the SPA build, `/media` and `/library` media Docker: see [`deploy/`](deploy/README.md) for the two images (SPA on static
directly, and proxying `/api` to Waitress/Django. See `frontend/` design docs for the UI nginx, API on gunicorn), the three compose variants (both / frontend-only /
specification. backend-only) behind a shared nginx service and a Tailscale sidecar, and the
public-funnel or tailnet-only serve configs. `deploy/push_*.sh` builds and
pushes the multi-arch images to the Gitea registry.
## Random image endpoint
Used by the SPA's Random page and by shell greetings (fish_greeting +
fastfetch):
```bash
curl -H "Authorization: Token <token>" \
"https://j621.example.ts.net/api/random/?rating=s,q&fastfetch=1"
```
```json
{
"j_id": "J-59",
"filename": "J-59.jpg",
"extension": "jpg",
"rating": "e",
"url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…",
"download_url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…&download=1",
"thumbnail_url": "https://j621.example.ts.net/api/files/J-59/thumbnail/?sig=…",
"fastfetch": true
}
```
- `rating` — comma separated subset of `s`, `q`, `e` (default: any).
- `fastfetch=1`, or any request whose User-Agent contains `fastfetch`, limits
the roll to `png`/`jpg`/`gif` so terminals can display it. Images are the
only candidates in both modes.
- `url` is absolute, and signed for authenticated callers, so fastfetch can
load it without headers. Guests get an unsigned URL and only see
guest-visible items.
- `/random` and `/random/` are aliases of `/api/random/` for scripts; 404 when
nothing matches the filters.
## Licence ## Licence
+5
View File
@@ -21,6 +21,11 @@ they land.
blacklisted items) blacklisted items)
- [x] Status filter (matched / not_found / deleted / custom / unknown) - [x] Status filter (matched / not_found / deleted / custom / unknown)
- [x] **Random image** endpoint and page: `GET /api/random/` (aliases
`/random`, `/random/`) with `rating=s,q,e` filters; fastfetch mode
(`?fastfetch=1` or a Fastfetch User-Agent) only returns png/jpg/gif as
JSON with a signed absolute link, for the fish_greeting scripts; the
`/random` SPA page rolls with rating pills and the `R` key
- [x] **Ephemeral similarity check** (`/similar`) - [x] **Ephemeral similarity check** (`/similar`)
- [x] Drop a file: exact MD5 match, perceptual matches against the library, - [x] Drop a file: exact MD5 match, perceptual matches against the library,
and e621 IQDB candidates (auto-run for images) and e621 IQDB candidates (auto-run for images)
+23
View File
@@ -17,6 +17,7 @@ import shutil
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
from unittest import mock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import signing from django.core import signing
@@ -433,6 +434,28 @@ class RemoteUrlTests(SecurityTestCase):
"https://static1.e621.net/data/x.png", "https://static1.e621.net/data/x.png",
) )
@mock.patch("requests.get")
def test_redirects_off_the_allowlist_are_refused(self, mocked_get):
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
redirect.headers = {"Location": "http://127.0.0.1:8000/health"}
mocked_get.return_value = redirect
with self.assertRaises(services.RemoteUrlError):
services.open_remote("https://static1.e621.net/x.png")
# The internal address was never requested: only the first hop was.
self.assertEqual(mocked_get.call_count, 1)
redirect.close.assert_called()
@mock.patch("requests.get")
def test_redirects_within_the_allowlist_are_followed(self, mocked_get):
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
redirect.headers = {"Location": "https://static2.e621.net/x.png"}
final = mock.Mock(is_redirect=False, is_permanent_redirect=False)
mocked_get.side_effect = [redirect, final]
self.assertIs(services.open_remote("https://static1.e621.net/x.png"), final)
self.assertEqual(mocked_get.call_count, 2)
def test_download_creation_rejects_internal_urls(self): def test_download_creation_rejects_internal_urls(self):
uploader = self.client_for("sec-uploader") uploader = self.client_for("sec-uploader")
for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"): for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"):
+137
View File
@@ -0,0 +1,137 @@
"""Tests for the random image endpoint (`/api/random/`, `/random`).
Used by the SPA's Random page and by shell greeting scripts (fish_greeting
with fastfetch), so the response contract matters:
* JSON with an absolute, directly fetchable URL,
* signed for authenticated callers (image viewers send no headers),
* fastfetch mode restricted to png/jpg/gif,
* rating filters and guest visibility applied server-side.
"""
import hashlib
import shutil
import tempfile
import time
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from rest_framework.authtoken.models import Token
from apps.library.models import MediaItem, MediaLocation
User = get_user_model()
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "apng"}
FASTFETCH_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
class RandomItemTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-random-")
cls._watched = Path(cls._tmp) / "library"
cls._watched.mkdir(parents=True, exist_ok=True)
cls._settings = override_settings(
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
)
cls._settings.enable()
@classmethod
def tearDownClass(cls):
cls._settings.disable()
shutil.rmtree(cls._tmp, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(
username="random-user", password="random-pass-123456"
)
token = Token.objects.create(user=self.user)
self.authed = Client()
self.authed.defaults["HTTP_AUTHORIZATION"] = f"Token {token.key}"
self.guest = Client()
def make_item(self, label, extension, rating, *, hidden=False):
path = self._watched / f"{label}.{extension}"
path.write_bytes(b"random-" + label.encode())
item = MediaItem.objects.create(
md5=hashlib.md5(label.encode()).hexdigest(),
size=path.stat().st_size,
rating=rating,
uploaded_by=self.user,
)
MediaLocation.objects.create(
item=item, path=str(path), rel_path=path.name, mtime=time.time()
)
if hidden:
MediaItem.objects.filter(pk=item.pk).update(hidden_from_guests=True)
return item
def test_returns_image_with_signed_absolute_url(self):
item = self.make_item("plain", "png", "s")
response = self.authed.get("/api/random/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["j_id"], f"J-{item.id}")
self.assertEqual(data["extension"], "png")
self.assertEqual(data["kind"], "image")
self.assertEqual(data["rating"], "s")
self.assertTrue(data["url"].startswith("http"))
self.assertIn("sig=", data["url"])
self.assertIn("download=1", data["download_url"])
self.assertFalse(data["fastfetch"])
def test_guest_url_is_unsigned_and_still_serves(self):
self.make_item("guest", "jpg", "s")
data = self.guest.get("/api/random/").json()
self.assertNotIn("sig=", data["url"])
path = data["url"].replace("http://testserver", "")
self.assertEqual(self.guest.get(path).status_code, 200)
def test_default_mode_returns_images_only(self):
self.make_item("movie", "mp4", "s")
self.make_item("picture", "webp", "s")
for _ in range(10):
extension = self.authed.get("/api/random/").json()["extension"]
self.assertIn(extension, IMAGE_EXTENSIONS)
def test_fastfetch_mode_flag_and_user_agent_restrict_formats(self):
self.make_item("movie", "mp4", "s")
self.make_item("modern", "webp", "s")
self.make_item("picture", "png", "s")
self.make_item("animation", "gif", "s")
attempts = [("flag", {"fastfetch": "1"}, {}), ("ua", {}, {"HTTP_USER_AGENT": "fastfetch/2.18.1"})]
for label, params, headers in attempts:
for _ in range(15):
response = self.authed.get("/api/random/", params, **headers)
self.assertEqual(response.status_code, 200, label)
data = response.json()
self.assertIn(data["extension"], FASTFETCH_EXTENSIONS, label)
self.assertTrue(data["fastfetch"], label)
def test_rating_filter(self):
self.make_item("safe", "png", "s")
explicit = self.make_item("explicit", "png", "e")
for _ in range(10):
data = self.authed.get("/api/random/", {"rating": "e"}).json()
self.assertEqual(data["j_id"], f"J-{explicit.id}")
self.assertEqual(data["rating"], "e")
self.assertEqual(self.authed.get("/api/random/", {"rating": "q"}).status_code, 404)
def test_guests_never_receive_hidden_items(self):
self.make_item("hidden", "png", "s", hidden=True)
self.assertEqual(self.guest.get("/api/random/").status_code, 404)
self.assertEqual(self.authed.get("/api/random/").status_code, 200)
def test_no_match_returns_404(self):
self.make_item("movie", "mp4", "s") # images only
self.assertEqual(self.authed.get("/api/random/").status_code, 404)
def test_short_top_level_alias(self):
self.make_item("alias", "gif", "s")
self.assertEqual(self.guest.get("/random/").status_code, 200)
self.assertEqual(self.guest.get("/random").status_code, 200)
+2
View File
@@ -17,6 +17,7 @@ from .views import (
DownloadTaskViewSet, DownloadTaskViewSet,
MatchTaskViewSet, MatchTaskViewSet,
MediaItemViewSet, MediaItemViewSet,
RandomItemView,
) )
router = DefaultRouter() router = DefaultRouter()
@@ -28,6 +29,7 @@ router.register("similarity", SimilarityCheckViewSet, basename="similarity")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
path("random/", RandomItemView.as_view(), name="random_item"),
path("online/file/", ClientDownloadView.as_view(), name="client_download"), path("online/file/", ClientDownloadView.as_view(), name="client_download"),
path( path(
"duplicates/md5/", "duplicates/md5/",
+94 -1
View File
@@ -520,6 +520,99 @@ class MatchTaskViewSet(
return Response({"success": True}) 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): class ClientDownloadView(APIView):
"""Stream an e621 file straight to the browser (no library write).""" """Stream an e621 file straight to the browser (no library write)."""
@@ -562,7 +655,7 @@ class ClientDownloadView(APIView):
"Content-Type", "application/octet-stream" "Content-Type", "application/octet-stream"
) )
name = get_valid_filename( name = get_valid_filename(
filename or Path(parsed.path).name or "download" filename or Path(urlparse(url).path).name or "download"
) )
def stream(): def stream():
+5
View File
@@ -4,6 +4,7 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from apps.core.views import HealthView from apps.core.views import HealthView
from apps.library.views import RandomItemView
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@@ -15,6 +16,10 @@ urlpatterns = [
# Liveness probe for uptime monitors (no /api prefix, no auth). # Liveness probe for uptime monitors (no /api prefix, no auth).
path("health", HealthView.as_view(), name="health"), path("health", HealthView.as_view(), name="health"),
path("health/", HealthView.as_view()), path("health/", HealthView.as_view()),
# Short alias for shell greeting scripts (fish_greeting + fastfetch);
# /api/random/ is the canonical path.
path("random", RandomItemView.as_view(), name="random"),
path("random/", RandomItemView.as_view()),
] ]
if settings.DEBUG: if settings.DEBUG:
+34
View File
@@ -17,6 +17,13 @@ map $http_x_forwarded_proto $j621_forwarded_proto {
"" $scheme; "" $scheme;
} }
# /random is both the SPA route and the shell-greeting shortcut: browsers
# (Accept: text/html) get the SPA, scripts (curl/wget/fetch) get the API JSON.
map $http_accept $j621_random_target {
default @j621_random_api;
~*text/html @j621_random_spa;
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -26,6 +33,33 @@ server {
return 200 "ok\n"; return 200 "ok\n";
} }
location ~ ^/random/?$ {
error_page 418 = $j621_random_target;
return 418;
}
location @j621_random_api {
set $j621_backend http://backend:8000;
proxy_pass $j621_backend$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $j621_forwarded_proto;
}
location @j621_random_spa {
set $j621_frontend http://frontend:80;
proxy_pass $j621_frontend$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $j621_forwarded_proto;
}
location ~ ^/(api|admin|static|health)(/|$) { location ~ ^/(api|admin|static|health)(/|$) {
set $j621_backend http://backend:8000; set $j621_backend http://backend:8000;
proxy_pass $j621_backend$request_uri; proxy_pass $j621_backend$request_uri;
+9
View File
@@ -23,6 +23,7 @@ import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage"; import OnlinePage from "@/features/online/OnlinePage";
import PoolDetailPage from "@/features/pools/PoolDetailPage"; import PoolDetailPage from "@/features/pools/PoolDetailPage";
import PoolsPage from "@/features/pools/PoolsPage"; import PoolsPage from "@/features/pools/PoolsPage";
import RandomPage from "@/features/random/RandomPage";
import SimilarPage from "@/features/similar/SimilarPage"; import SimilarPage from "@/features/similar/SimilarPage";
import { SetupPage } from "@/features/setup/SetupPage"; import { SetupPage } from "@/features/setup/SetupPage";
import StatsPage from "@/features/stats/StatsPage"; import StatsPage from "@/features/stats/StatsPage";
@@ -99,6 +100,14 @@ export default function App() {
</RequireBackend> </RequireBackend>
} }
/> />
<Route
path="/random"
element={
<RequireBackend>
<RandomPage />
</RequireBackend>
}
/>
<Route path="/online" element={<OnlinePage />} /> <Route path="/online" element={<OnlinePage />} />
<Route path="/online/view/:postId" element={<PostRedirect />} /> <Route path="/online/view/:postId" element={<PostRedirect />} />
<Route path="/pools" element={<PoolsPage />} /> <Route path="/pools" element={<PoolsPage />} />
+1
View File
@@ -82,6 +82,7 @@ export function AppShell() {
const navItems = [ const navItems = [
...(backend ? [{ to: "/", label: "Library" }] : []), ...(backend ? [{ to: "/", label: "Library" }] : []),
...(backend ? [{ to: "/random", label: "Random" }] : []),
{ to: "/online", label: "Online" }, { to: "/online", label: "Online" },
{ to: "/pools", label: "Pools" }, { to: "/pools", label: "Pools" },
...(backend && user ? [{ to: "/followed", label: "Followed" }] : []), ...(backend && user ? [{ to: "/followed", label: "Followed" }] : []),
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Cable, Cable,
Copy, Copy,
Dices,
ExternalLink, ExternalLink,
FolderOpen, FolderOpen,
Globe, Globe,
@@ -124,6 +125,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: FolderOpen, icon: FolderOpen,
run: () => navigate("/"), run: () => navigate("/"),
}, },
{
id: "random",
label: "Roll a random image",
icon: Dices,
run: () => navigate("/random"),
},
] ]
: []), : []),
{ {
+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>
);
}
+15
View File
@@ -1,3 +1,18 @@
export interface RandomItem {
j_id: string;
md5: string;
filename: string;
extension: string;
kind: "image";
rating: string;
size: number;
e621_post_id: number | null;
url: string;
download_url: string;
thumbnail_url: string;
fastfetch: boolean;
}
export interface UserPreferences { export interface UserPreferences {
landing_page?: "library" | "online" | "pools" | "followed"; landing_page?: "library" | "online" | "pools" | "followed";
ratings?: string[]; ratings?: string[];