From f8667c1037b647ad134ed62598b4209930f503d2 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 13:06:36 -0500 Subject: [PATCH] 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. --- README.md | 43 ++++- ROADMAP.md | 5 + backend/apps/core/tests/test_security.py | 23 +++ backend/apps/library/tests/__init__.py | 0 backend/apps/library/tests/test_random.py | 137 +++++++++++++ backend/apps/library/urls.py | 2 + backend/apps/library/views.py | 95 ++++++++- backend/config/urls.py | 5 + deploy/nginx-proxy.conf | 34 ++++ frontend/src/App.tsx | 9 + frontend/src/components/AppShell.tsx | 1 + frontend/src/components/CommandPalette.tsx | 7 + frontend/src/features/random/RandomPage.tsx | 202 ++++++++++++++++++++ frontend/src/lib/types.ts | 15 ++ 14 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 backend/apps/library/tests/__init__.py create mode 100644 backend/apps/library/tests/test_random.py create mode 100644 frontend/src/features/random/RandomPage.tsx diff --git a/README.md b/README.md index 7b7dbe0..44087dc 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D ## 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 ``` @@ -33,9 +33,44 @@ npm run dev # http://localhost:5173, proxies /api to ### Production -Not wired up yet — planned: Nginx serving the SPA build, `/media` and `/library` media -directly, and proxying `/api` to Waitress/Django. See `frontend/` design docs for the UI -specification. +Docker: see [`deploy/`](deploy/README.md) for the two images (SPA on static +nginx, API on gunicorn), the three compose variants (both / frontend-only / +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 " \ + "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 diff --git a/ROADMAP.md b/ROADMAP.md index 8bd2371..ac6d1d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,11 @@ they land. blacklisted items) - [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] Drop a file: exact MD5 match, perceptual matches against the library, and e621 IQDB candidates (auto-run for images) diff --git a/backend/apps/core/tests/test_security.py b/backend/apps/core/tests/test_security.py index 461bf6a..87055c9 100644 --- a/backend/apps/core/tests/test_security.py +++ b/backend/apps/core/tests/test_security.py @@ -17,6 +17,7 @@ import shutil import tempfile import time from pathlib import Path +from unittest import mock from django.contrib.auth import get_user_model from django.core import signing @@ -433,6 +434,28 @@ class RemoteUrlTests(SecurityTestCase): "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): uploader = self.client_for("sec-uploader") for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"): diff --git a/backend/apps/library/tests/__init__.py b/backend/apps/library/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/library/tests/test_random.py b/backend/apps/library/tests/test_random.py new file mode 100644 index 0000000..8b17d26 --- /dev/null +++ b/backend/apps/library/tests/test_random.py @@ -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) diff --git a/backend/apps/library/urls.py b/backend/apps/library/urls.py index 39541b1..b896017 100644 --- a/backend/apps/library/urls.py +++ b/backend/apps/library/urls.py @@ -17,6 +17,7 @@ from .views import ( DownloadTaskViewSet, MatchTaskViewSet, MediaItemViewSet, + RandomItemView, ) router = DefaultRouter() @@ -28,6 +29,7 @@ router.register("similarity", SimilarityCheckViewSet, basename="similarity") urlpatterns = [ path("", include(router.urls)), + path("random/", RandomItemView.as_view(), name="random_item"), path("online/file/", ClientDownloadView.as_view(), name="client_download"), path( "duplicates/md5/", diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index 6fc7fce..7a25277 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -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(): diff --git a/backend/config/urls.py b/backend/config/urls.py index 11f7b42..ecb510f 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,6 +4,7 @@ from django.contrib import admin from django.urls import include, path from apps.core.views import HealthView +from apps.library.views import RandomItemView urlpatterns = [ path("admin/", admin.site.urls), @@ -15,6 +16,10 @@ urlpatterns = [ # Liveness probe for uptime monitors (no /api prefix, no auth). path("health", HealthView.as_view(), name="health"), 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: diff --git a/deploy/nginx-proxy.conf b/deploy/nginx-proxy.conf index c9c8419..0f71be3 100644 --- a/deploy/nginx-proxy.conf +++ b/deploy/nginx-proxy.conf @@ -17,6 +17,13 @@ map $http_x_forwarded_proto $j621_forwarded_proto { "" $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 { listen 80; server_name _; @@ -26,6 +33,33 @@ server { 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)(/|$) { set $j621_backend http://backend:8000; proxy_pass $j621_backend$request_uri; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 31d40fd..d542e2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import Md5Redirect from "@/features/library/Md5Redirect"; import OnlinePage from "@/features/online/OnlinePage"; import PoolDetailPage from "@/features/pools/PoolDetailPage"; import PoolsPage from "@/features/pools/PoolsPage"; +import RandomPage from "@/features/random/RandomPage"; import SimilarPage from "@/features/similar/SimilarPage"; import { SetupPage } from "@/features/setup/SetupPage"; import StatsPage from "@/features/stats/StatsPage"; @@ -99,6 +100,14 @@ export default function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index b46a3be..4caee20 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -82,6 +82,7 @@ export function AppShell() { const navItems = [ ...(backend ? [{ to: "/", label: "Library" }] : []), + ...(backend ? [{ to: "/random", label: "Random" }] : []), { to: "/online", label: "Online" }, { to: "/pools", label: "Pools" }, ...(backend && user ? [{ to: "/followed", label: "Followed" }] : []), diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index e1c4c51..c3cdbe3 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Cable, Copy, + Dices, ExternalLink, FolderOpen, Globe, @@ -124,6 +125,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: FolderOpen, run: () => navigate("/"), }, + { + id: "random", + label: "Roll a random image", + icon: Dices, + run: () => navigate("/random"), + }, ] : []), { diff --git a/frontend/src/features/random/RandomPage.tsx b/frontend/src/features/random/RandomPage.tsx new file mode 100644 index 0000000..a6a194b --- /dev/null +++ b/frontend/src/features/random/RandomPage.tsx @@ -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 = { + 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([]); + + 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(`/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 ( +
+
+
+

Random

+

+ A random image from the library. Space out the fun — press{" "} + R or roll + again. +

+
+
+ {RATING_FILTERS.map((option) => ( + + ))} + + {ratings.length ? "" : "any rating"} + +
+
+ + {query.isPending ? ( +
+ +
+ ) : query.isError ? ( + + No image matches those ratings.{" "} + + Browse the library + {" "} + to see what is in there. + + } + /> + ) : item ? ( +
+
+ {item.filename} + {item.rating ? ( + + {item.rating} + + ) : null} +
+ +
+ + {item.j_id} + + + {item.filename} + + + {item.extension.toUpperCase()} · {formatBytes(item.size)} + + {item.e621_post_id ? ( + + e621 #{item.e621_post_id} ↗ + + ) : null} + +
+ + Open + + + Download + + +
+
+
+ ) : null} + +

+ Shell greetings: /api/random/?fastfetch=1{" "} + (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 /random for scripts. +

+
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 686f460..82b46cb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { landing_page?: "library" | "online" | "pools" | "followed"; ratings?: string[];