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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user