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.
138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
"""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)
|