Compare commits

...
10 Commits
Author SHA1 Message Date
JakeBreath 1170e6e9c1 Updates 2026-09-18 14:11:10 -05:00
JakeBreath b3ceac52ed fish greeting: win over distro configs that define fish_greeting inline
On CachyOS (and OMF-style setups) config.fish sources a distro file that
defines fish_greeting while the shell starts. A function defined that way
beats autoloading from functions/, so the installed greeting never ran.

install.fish now appends a guarded block to ~/.config/fish/config.fish that
sources the greeting after everything else (idempotent via a marker, skipped
with --no-config), and the README documents the symptom, the check
(functions --details fish_greeting) and the manual one-liner.

Verified in a sandbox HOME that reproduces the CachyOS setup: before the
install fish resolves the distro file and prints its message, after it
resolves ~/.config/fish/functions/fish_greeting.fish and runs ours; a second
install leaves a single block. Applied to this machine's real config as
well, where fish_greeting now resolves to the user function and the
__j621_fetch_random helper is loaded.
2026-09-18 13:48:02 -05:00
JakeBreath bcff184a64 Make extras/fish_greeting/install.fish executable
It has a '#!/usr/bin/env fish' shebang but was committed as mode 644, so
./install.fish answered 'Permission denied' on a fresh clone. Mode is now
100755 like the deploy scripts; 'fish install.fish' worked either way.
2026-09-18 13:43:12 -05:00
JakeBreath 770b1e5ee6 Scoped API tokens for the random endpoint, with a management page
Backend: a GreetingToken model stores only a SHA-256 hash of a j621r_…
key (shown once at creation) plus label, prefix, created/last-used. A
dedicated GreetingTokenAuthentication understands the usual
'Authorization: Token …' header but is registered only on RandomItemView
(alongside the normal token auth), so a greeting token authenticates
/api/random/ and is rejected with 401 everywhere else — exactly the scope
shell greetings need. Endpoints: GET/POST /api/auth/greeting-tokens/ and
DELETE /api/auth/greeting-tokens/{id}/ (own tokens only; the list never
returns keys or hashes).

Frontend: /tokens page (Account → Shell tokens card, command palette entry)
lists tokens with label, prefix, created/last-used and revoke (shared
confirm dialog). Creating one shows the key with Copy and 'Copy for fish'
buttons plus a pointer to extras/fish_greeting.

Tests: apps/accounts/tests/test_greeting_tokens.py — 9 tests covering
create-once semantics and hashing, hidden keys in listings, the scope
guarantee (random 200 with a signed URL; 401 on files, storage, me, tags
cloud, delete and the token list itself), unknown/revoked keys, cross-user
revocation, last-used tracking and label limits.

Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
2026-09-18 13:37:29 -05:00
JakeBreath 2d9493d9fe fish_greeting installer asks for the API origin and probes the backend
install.fish now:
- asks for the API origin (default https://j621.rainbow-herring.ts.net) and
  an optional token when run interactively, or takes --url/--token;
- writes ~/.config/j621Greeting/config.fish with mode 600 (it may hold a
  token), keeps an existing config unless --force/--url is given, and
  --no-prompt runs fully unattended;
- verifies the setup: /health must answer 'ok' and a fastfetch random roll is
  attempted, reporting the API's own message when it finds nothing; exits
  non-zero when the backend is unreachable so scripts notice.

README documents the prompts/flags, the chmod 600 config, and that the
greeting is designed to run without a token (guest mode: unsigned links,
guest-visible items only) with a token adding signed links and hidden items.

Tested with fish 4.9.3 in throwaway HOME dirs: flag-driven install with a
token, interactive install with a piped origin and no token, re-run keeping
the existing config, and an unreachable backend exiting 1.
2026-09-18 13:22:55 -05:00
JakeBreath aee29de34a Port the fish_greeting shell greeting to the new API
The original script (J621-Django/extras/fish_greeting system) downloaded
image bytes from /random/?rating=X and read the X-File-* headers. The new
API answers with JSON and a signed link, so the greeting now:

- asks /api/random/?fastfetch=1&rating=<mode> for JSON;
- parses url/j_id/filename/md5 with jq, python3, or a grep/sed fallback;
- downloads the signed link and keeps the original display path (fastfetch
  kitty/kitty-icat logos, gifsicle preprocessing for GIFs, recursion guard,
  logging with rotation, greeting_mode 0/1/2 = NSFW/SFW/Questionable);
- is configured through ~/.config/j621Greeting/config.fish or universal
  variables (J621_BASE, J621_WEB, J621_TOKEN, J621_FASTFETCH_CONFIG) instead
  of a hardcoded host, and prints the /detail/<J-ID> link on J621_WEB;
- reports the API's own 404 message when a rating has no images, and keeps
  curl quiet so failures do not spill into the greeting.

extras/fish_greeting/ contains the function, an install.fish (copies it into
the fish functions dir, creates the config once, checks dependencies) and a
README with the old-vs-new table and troubleshooting. The existing
gm-switch helper and .desktop launchers keep working (same mode file).

Tested with fish 4.9.3: syntax check on every file, guest and token runs
against the dev API (unsigned vs signed URLs), the empty-rating and
unreachable-API paths, and the grep/sed fallback with jq and python3
unavailable.
2026-09-18 13:14:41 -05:00
JakeBreath f8667c1037 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.
2026-09-18 13:06:36 -05:00
JakeBreath f25526782c Run the periodic commands in a scheduler service (no host cron)
Adds deploy/scheduler-entrypoint.sh to the backend image (entrypoint
j621-scheduler) and a scheduler service to the four composes that have a
backend. It waits until the database and migrations are ready, runs every
job once, then keeps to the intervals:

  sync_followed_tags + sync_followed_pools   every 30 min (J621_SYNC_EVERY)
  cleanup_similarity                         hourly      (J621_CLEAN_EVERY)
  refresh_guest_blacklist                    daily       (J621_BLACKLIST_EVERY)

It shares the backend image, media volume and env file, so commands see
the same library and database; failures are logged and retried next
interval. Output goes to docker compose logs scheduler; the frontend-only
composes have no backend and therefore no scheduler.

Verified against a real stack: the scheduler waited for migrations, ran all
four commands on start (follow syncs as anonymous, similarity cleanup, and
a guest blacklist refresh that pulled the real 14-tag list from e621), and
kept looping. All six composes still validate.
2026-09-18 12:51:17 -05:00
JakeBreath 93cce6b9fd deploy/gen_env.sh: generate .env with openssl secrets
Builds deploy/.env from .env.example, generating SECRET_KEY and both
database passwords with openssl (base64/hex only, so nothing needs quoting
in the env file or the compose parser). Derives TS_HOSTNAME and
ALLOWED_HOSTS from the tailnet hostname, optionally sets
CORS_ALLOWED_ORIGINS/CSRF_TRUSTED_ORIGINS for split deployments, forces
DEBUG=False and writes the file with mode 600.

Modes: --no-prompt (defaults only), --update (refresh hostnames/auth key
while keeping the existing secrets, reading the stored FQDN from
ALLOWED_HOSTS), --force (rotate everything, with the SECRET_KEY warning in
the docs). Refuses to overwrite an existing file otherwise.

Verified: all modes, updated FQDN preservation, mode 600, and
docker compose config accepting the generated file.
2026-09-18 12:46:56 -05:00
JakeBreath 30b1a1a4b0 Tailnet-only (Serve, no Funnel) compose variants
Three more composes — compose.tailnet.yml, compose.tailnet.frontend.yml,
compose.tailnet.backend.yml — mirror the funnel set exactly but mount
serve.default/frontend/backend.tailnet.json, which drop AllowFunnel. The
sidecar still registers and serves HTTPS with a tailnet certificate, but
nothing is exposed publicly; Serve also needs no ACL change.

Project names carry a -tailnet suffix so both sets can coexist, and the
README explains that each set needs its own data directory (or host), plus
how to switch a host between funnel and tailnet by starting the other file
with the same .env.

Verified: all six composes validate with docker compose config, and the
six serve configs split cleanly into funnel (AllowFunnel present) and
tailnet-only (absent).
2026-09-18 11:21:48 -05:00
44 changed files with 2505 additions and 19 deletions
+1
View File
@@ -25,6 +25,7 @@ frontend/dist/
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
.directory
# Tooling # Tooling
*.log *.log
+10 -4
View File
@@ -34,10 +34,16 @@ Project constraints (do not regress):
- deploy/ is the deployment source of truth: J621-Frontend (SPA on static - deploy/ is the deployment source of truth: J621-Frontend (SPA on static
nginx) and J621-Backend (gunicorn + whitenoise, ffmpeg, migrations on nginx) and J621-Backend (gunicorn + whitenoise, ffmpeg, migrations on
start), three compose variants (both / frontend-only / backend-only) behind start), three compose variants (both / frontend-only / backend-only) behind
a shared nginx proxy service, and a Tailscale sidecar per compose whose a shared nginx proxy service, and a Tailscale sidecar per compose.
serve configs only use funnel ports 443 / 8443 / 10000. Images are pushed serve.*.json are the public Funnel variants (only ports 443 / 8443 / 10000);
to the Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, serve.*.tailnet.json + compose.tailnet*.yml are the same stacks without
GIT_HASH baked in for the version pill). AllowFunnel (tailnet-only, no public exposure). Images are pushed to the
Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, GIT_HASH
baked in for the version pill); deploy/gen_env.sh generates .env with
openssl secrets (--update keeps SECRET_KEY, --force rotates it).
- Periodic commands (follow syncs, similarity cleanup, guest blacklist
refresh) run in the composes' `scheduler` service — the backend image with
the j621-scheduler entrypoint, intervals via J621_*_EVERY. No host cron.
- Security/permission tests live in backend/apps/core/tests and need a - Security/permission tests live in backend/apps/core/tests and need a
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
+50 -4
View File
@@ -5,8 +5,10 @@ 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
deploy/ Docker images, compose variants and Tailscale serve configs
extras/ shell integrations (fish_greeting with fastfetch)
``` ```
## Development ## Development
@@ -33,9 +35,53 @@ 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. Behind
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
the unambiguous path for scripts; 404 when nothing matches the filters.
- A ready-made shell greeting that uses this endpoint lives in
[`extras/fish_greeting/`](extras/fish_greeting/README.md).
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section
(also `/tokens`) issues `j621r_…` tokens that only authenticate
`/api/random/` — the rest of the API rejects them. They are stored as
hashes, shown once, and revocable any time
(`/api/auth/greeting-tokens/`).
## Licence ## Licence
+15 -4
View File
@@ -21,6 +21,15 @@ 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
- **Scoped `j621r_…` greeting tokens** (Account → Shell tokens, `/tokens`):
only `/api/random/` accepts them, they are stored hashed, shown once and
revocable; `install.fish` in `extras/fish_greeting` fills them into the
shell greeting config
- [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)
@@ -145,10 +154,12 @@ Files now stage first and are resolved before entering the library.
## 6. Infrastructure ## 6. Infrastructure
- [ ] Guest blacklist refresh on a timer (in-container scheduler) - [x] Guest blacklist refresh on a timer (the composes' `scheduler` service;
- [ ] Follow sync on a timer (in-container scheduler, e.g. every 30 minutes) `J621_BLACKLIST_EVERY`, default daily)
- [ ] Similarity temp cleanup on a timer (in-container scheduler; the TTL - [x] Follow sync on a timer (same scheduler: `sync_followed_tags` +
also cleans lazily when new checks are created) `sync_followed_pools`, default every 30 minutes)
- [x] Similarity temp cleanup on a timer (same scheduler, default hourly;
the TTL also cleans lazily when new checks are created)
- [x] Production setup: two Docker images (SPA on static nginx, API on - [x] Production setup: two Docker images (SPA on static nginx, API on
gunicorn + whitenoise with ffmpeg) and three compose variants (both / gunicorn + whitenoise with ffmpeg) and three compose variants (both /
frontend-only / backend-only) behind a shared nginx proxy service, each frontend-only / backend-only) behind a shared nginx proxy service, each
+42
View File
@@ -0,0 +1,42 @@
"""Authentication for scope-limited bearer tokens.
`GreetingTokenAuthentication` understands the same header a normal API token
uses (``Authorization: Token <key>``) but only resolves tokens issued for the
random-image endpoint. It is registered per-view (currently only
`RandomItemView`), so a greeting token is rejected everywhere else by the
regular DRF token authentication.
"""
from rest_framework import authentication, exceptions
from .models import GreetingToken
class GreetingTokenAuthentication(authentication.BaseAuthentication):
keyword = b"token"
def authenticate_header(self, request):
# DRF answers 401 (instead of 403) for AuthenticationFailed only when
# the first authenticator can name the scheme.
return "Token"
def authenticate(self, request):
header = authentication.get_authorization_header(request).split()
if not header or header[0].lower() != self.keyword:
return None
if len(header) != 2:
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
key = header[1].decode()
except UnicodeError:
raise exceptions.AuthenticationFailed("Invalid token header.")
# Not one of ours: let the regular token authentication handle it.
if not key.startswith(GreetingToken.PREFIX):
return None
token = GreetingToken.resolve(key)
if token is None:
raise exceptions.AuthenticationFailed("Invalid token.")
token.touch()
return (token.user, token)
@@ -0,0 +1,30 @@
# Generated by Django 6.1.1 on 2026-09-18 18:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_encrypt_e621_api_keys'),
]
operations = [
migrations.CreateModel(
name='GreetingToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key_hash', models.CharField(max_length=64, unique=True)),
('prefix', models.CharField(max_length=16)),
('label', models.CharField(blank=True, default='', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='greeting_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+64
View File
@@ -1,5 +1,9 @@
import secrets
from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils import timezone
class User(AbstractUser): class User(AbstractUser):
@@ -50,3 +54,63 @@ class User(AbstractUser):
return bool( return bool(
self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF
) )
def hash_bearer_token(value):
"""SHA-256 of a high-entropy bearer token (no salt needed)."""
import hashlib
return hashlib.sha256(value.encode()).hexdigest()
class GreetingToken(models.Model):
"""Long-lived token that only authenticates the random-image endpoint.
Meant for shell greetings and similar scripts, so it is safe to keep in a
config file: it cannot read the library, upload, or touch an account. Only
the SHA-256 hash is stored; the plaintext is returned once at creation.
"""
PREFIX = "j621r_"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="greeting_tokens",
)
key_hash = models.CharField(max_length=64, unique=True)
prefix = models.CharField(max_length=16)
label = models.CharField(max_length=100, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.prefix}… ({self.user})"
@classmethod
def issue(cls, user, label=""):
"""Create a token and return ``(token, plaintext_key)``."""
key = cls.PREFIX + secrets.token_hex(20)
token = cls.objects.create(
user=user,
key_hash=hash_bearer_token(key),
prefix=key[:12],
label=label.strip()[:100],
)
return token, key
@classmethod
def resolve(cls, key):
if not key.startswith(cls.PREFIX):
return None
return (
cls.objects.select_related("user")
.filter(key_hash=hash_bearer_token(key))
.first()
)
def touch(self):
GreetingToken.objects.filter(pk=self.pk).update(last_used_at=timezone.now())
+8 -1
View File
@@ -6,7 +6,7 @@ from rest_framework import serializers
from apps.library.services import VIDEO_EXTENSIONS from apps.library.services import VIDEO_EXTENSIONS
from apps.library.services import signed_media_url as signed_library_url from apps.library.services import signed_media_url as signed_library_url
from .models import User from .models import User, GreetingToken
def signed_media_url(request, item): def signed_media_url(request, item):
@@ -92,6 +92,13 @@ class UserUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False) role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
class GreetingTokenSerializer(serializers.ModelSerializer):
class Meta:
model = GreetingToken
fields = ["id", "prefix", "label", "created_at", "last_used_at"]
read_only_fields = fields
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, validators=[validate_password]) password = serializers.CharField(write_only=True, validators=[validate_password])
@@ -0,0 +1,166 @@
"""Scope-limited greeting tokens (`j621r_…`).
They exist so shell greetings and scripts can hold a credential that only
authenticates `/api/random/` — everything else must reject them — and they
are stored hashed, shown once.
"""
import hashlib
import json
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.accounts.models import GreetingToken, hash_bearer_token
from apps.library.models import MediaItem, MediaLocation
User = get_user_model()
def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
class GreetingTokenTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-tokens-")
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="token-user", password="token-pass-123456"
)
self.other = User.objects.create_user(
username="token-other", password="token-pass-123456"
)
self.client = self.api_client(self.user)
self.other_client = self.api_client(self.other)
# One image so the random endpoint can answer.
path = self._watched / "token-test.png"
path.write_bytes(b"token-test")
self.item = MediaItem.objects.create(
md5=hashlib.md5(b"token-test").hexdigest(),
size=path.stat().st_size,
rating="s",
uploaded_by=self.user,
)
MediaLocation.objects.create(
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
)
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def token_client(self, key):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = f"Token {key}"
return client
def issue(self, client=None, label=""):
response = jpost(client or self.client, "/api/auth/greeting-tokens/", {"label": label})
self.assertEqual(response.status_code, 201)
return response.json()
def test_create_returns_the_key_once_and_stores_only_a_hash(self):
created = self.issue(self.client, "shell")
key = created["key"]
self.assertTrue(key.startswith("j621r_"))
self.assertEqual(created["label"], "shell")
token = GreetingToken.objects.get(pk=created["id"])
self.assertEqual(token.key_hash, hash_bearer_token(key))
self.assertNotIn(key, token.key_hash)
self.assertEqual(token.prefix, key[:12])
self.assertIsNone(token.last_used_at)
def test_list_hides_keys_and_hashes(self):
self.issue(self.client, "one")
self.issue(self.client, "two")
rows = self.client.get("/api/auth/greeting-tokens/").json()
self.assertEqual([row["label"] for row in rows], ["two", "one"])
for row in rows:
self.assertNotIn("key", row)
self.assertNotIn("key_hash", row)
self.assertTrue(row["prefix"].startswith("j621r_"))
def test_token_authenticates_random_and_nothing_else(self):
key = self.issue(self.other_client, "shell")["key"]
client = self.token_client(key)
response = client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
for method, path, body in (
("get", "/api/files/", None),
("get", "/api/storage/", None),
("get", "/api/auth/me/", None),
("get", "/api/tags/cloud/", None),
("post", "/api/delete/", {"j_ids": []}),
("get", "/api/auth/greeting-tokens/", None),
):
call = getattr(client, method)
if body is None:
self.assertEqual(call(path).status_code, 401, path)
else:
self.assertEqual(jpost(client, path, body).status_code, 401, path)
def test_normal_api_token_still_authenticates_random(self):
response = self.client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
def test_unknown_greeting_key_is_rejected(self):
client = self.token_client("j621r_" + "0" * 40)
self.assertEqual(client.get("/api/random/").status_code, 401)
def test_revoked_token_stops_working(self):
created = self.issue(self.client, "temporary")
client = self.token_client(created["key"])
self.assertEqual(client.get("/api/random/").status_code, 200)
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 204)
self.assertEqual(client.get("/api/random/").status_code, 401)
self.assertFalse(GreetingToken.objects.filter(pk=created["id"]).exists())
def test_cannot_revoke_someone_elses_token(self):
created = self.issue(self.other_client, "theirs")
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 404)
self.assertEqual(self.token_client(created["key"]).get("/api/random/").status_code, 200)
def test_last_used_is_recorded(self):
created = self.issue(self.client, "used")
self.token_client(created["key"]).get("/api/random/")
token = GreetingToken.objects.get(pk=created["id"])
self.assertIsNotNone(token.last_used_at)
def test_long_labels_are_rejected(self):
response = jpost(
self.client, "/api/auth/greeting-tokens/", {"label": "x" * 101}
)
self.assertEqual(response.status_code, 400)
+12
View File
@@ -3,6 +3,8 @@ from django.urls import path
from .views import ( from .views import (
AvatarView, AvatarView,
E621CredentialsView, E621CredentialsView,
GreetingTokenDetailView,
GreetingTokenListView,
LoginView, LoginView,
LogoutView, LogoutView,
MeView, MeView,
@@ -18,4 +20,14 @@ urlpatterns = [
path("avatar/", AvatarView.as_view(), name="avatar"), path("avatar/", AvatarView.as_view(), name="avatar"),
path("preferences/", PreferencesView.as_view(), name="preferences"), path("preferences/", PreferencesView.as_view(), name="preferences"),
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"), path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
path(
"greeting-tokens/",
GreetingTokenListView.as_view(),
name="greeting_tokens",
),
path(
"greeting-tokens/<int:pk>/",
GreetingTokenDetailView.as_view(),
name="greeting_token",
),
] ]
+49 -1
View File
@@ -1,5 +1,6 @@
import logging import logging
from django.http import Http404
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
@@ -13,9 +14,10 @@ from apps.core.permissions import IsAppStaff
from apps.library.models import MediaItem from apps.library.models import MediaItem
from .crypto import encrypt_secret from .crypto import encrypt_secret
from .models import User from .models import GreetingToken, User
from .serializers import ( from .serializers import (
E621CredentialsSerializer, E621CredentialsSerializer,
GreetingTokenSerializer,
PreferencesSerializer, PreferencesSerializer,
RegisterSerializer, RegisterSerializer,
UserListSerializer, UserListSerializer,
@@ -157,6 +159,52 @@ class PreferencesView(APIView):
return Response(preferences) return Response(preferences)
class GreetingTokenListView(APIView):
"""List and create the caller's random-endpoint tokens.
The plaintext key is returned once on creation; only its hash is stored,
and it only authenticates `/api/random/` (see GreetingToken).
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tokens = GreetingToken.objects.filter(user=request.user)
return Response(GreetingTokenSerializer(tokens, many=True).data)
def post(self, request):
label = str(request.data.get("label") or "").strip()
if len(label) > 100:
return Response(
{"detail": "Label is too long (100 characters max)."},
status=status.HTTP_400_BAD_REQUEST,
)
token, key = GreetingToken.issue(request.user, label)
logger.info(
"Greeting token %s created by %s", token.prefix, request.user.username
)
return Response(
{**GreetingTokenSerializer(token).data, "key": key},
status=status.HTTP_201_CREATED,
)
class GreetingTokenDetailView(APIView):
"""Revoke one of the caller's tokens."""
permission_classes = [IsAuthenticated]
def delete(self, request, pk):
token = GreetingToken.objects.filter(pk=pk, user=request.user).first()
if token is None:
raise Http404
logger.info(
"Greeting token %s revoked by %s", token.prefix, request.user.username
)
token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserViewSet( class UserViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
+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/",
+101 -1
View File
@@ -12,12 +12,15 @@ from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.accounts.auth import GreetingTokenAuthentication
from . import e621, matching, services from . import e621, matching, services
from .downloads import reap_stale_downloads, start_download_task from .downloads import reap_stale_downloads, start_download_task
from .matching import reap_stale_match_tasks, start_match_task from .matching import reap_stale_match_tasks, start_match_task
@@ -520,6 +523,103 @@ 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.
Accepts the normal API token *and* the scope-limited greeting tokens
(``j621r_…``), which work here and nowhere else.
"""
authentication_classes = [GreetingTokenAuthentication, TokenAuthentication]
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 +662,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:
+6
View File
@@ -49,6 +49,12 @@ DB_ROOT_PASSWORD=j621root
# GUNICORN_THREADS=4 # GUNICORN_THREADS=4
# GUNICORN_TIMEOUT=120 # GUNICORN_TIMEOUT=120
# Scheduler intervals in seconds (the "scheduler" service runs the periodic
# management commands; see deploy/README.md)
# J621_SYNC_EVERY=1800
# J621_CLEAN_EVERY=3600
# J621_BLACKLIST_EVERY=86400
# Rate limits (per IP anonymous, per account signed in) # Rate limits (per IP anonymous, per account signed in)
# THROTTLE_ANON=120/min # THROTTLE_ANON=120/min
# THROTTLE_USER=600/min # THROTTLE_USER=600/min
+2 -1
View File
@@ -28,8 +28,9 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./ COPY backend/ ./
COPY deploy/backend-entrypoint.sh /usr/local/bin/j621-entrypoint COPY deploy/backend-entrypoint.sh /usr/local/bin/j621-entrypoint
COPY deploy/scheduler-entrypoint.sh /usr/local/bin/j621-scheduler
RUN chmod +x /usr/local/bin/j621-entrypoint \ RUN chmod +x /usr/local/bin/j621-entrypoint /usr/local/bin/j621-scheduler \
&& mkdir -p /app/media /app/logs \ && mkdir -p /app/media /app/logs \
&& python manage.py collectstatic --noinput && python manage.py collectstatic --noinput
+71 -3
View File
@@ -5,7 +5,9 @@ sidecar. Nothing is published on the host's ports and no system nginx or
reverse proxy is involved: the sidecar shares the nginx service's network reverse proxy is involved: the sidecar shares the nginx service's network
namespace and Tailscale Serve/Funnel exposes it. namespace and Tailscale Serve/Funnel exposes it.
| Compose | Services | Funnel | Serve config | ## Funnel set (public HTTPS)
| Compose | Services | Entry point | Serve config |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `compose.yml` (default) | mariadb, redis, backend, frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → backend + SPA | `serve.default.json` | | `compose.yml` (default) | mariadb, redis, backend, frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → backend + SPA | `serve.default.json` |
| `compose.frontend.yml` | frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → SPA only | `serve.frontend.json` | | `compose.frontend.yml` | frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → SPA only | `serve.frontend.json` |
@@ -15,15 +17,64 @@ Funnel can only expose ports **443**, **8443** and **10000**, so the separate
backend uses 8443. Change it in `serve.backend.json` (and `.env`) if you backend uses 8443. Change it in `serve.backend.json` (and `.env`) if you
prefer 10000. prefer 10000.
## Tailnet-only set (no Funnel)
The same three stacks without `AllowFunnel` in the serve config: the sidecar
still registers the node and serves over HTTPS with a tailnet certificate,
but only devices on your tailnet can reach it — nothing is exposed to the
public internet.
| Compose | Serve config | Reachable at |
| --- | --- | --- |
| `compose.tailnet.yml` (both) | `serve.default.tailnet.json` | `https://<host>.<tailnet>.ts.net` |
| `compose.tailnet.frontend.yml` | `serve.frontend.tailnet.json` | `https://<host>.<tailnet>.ts.net` |
| `compose.tailnet.backend.yml` | `serve.backend.tailnet.json` | `https://<host>.<tailnet>.ts.net:8443` |
```bash
docker compose -f compose.tailnet.yml up -d
# or compose.tailnet.frontend.yml / compose.tailnet.backend.yml
```
Notes:
- Project names are `…-tailnet` so both sets can be installed side by side.
- Serve needs no tailnet policy change (Funnel requires the `funnel` node
attribute in your ACLs), so this set works as soon as the sidecar joins.
- Both sets use the same `deploy/data` directory (library, database, logs).
Run one set per data directory — two MariaDB instances on one data dir would
corrupt it. Giving the tailnet set its own `TS_HOSTNAME` (or running it on a
second host) is the way to run both.
- Switching a host from funnel to tailnet (or back) is just starting the other
compose file with the same `.env`.
## Environment file
`gen_env.sh` builds `deploy/.env` from `.env.example`, generating `SECRET_KEY`
and both database passwords with `openssl`:
```bash
./gen_env.sh # asks for the Tailscale auth key + hostname(s)
./gen_env.sh --no-prompt # secrets and defaults only; fill TS_AUTHKEY later
./gen_env.sh --update # refresh hostnames/authkey, keep the existing secrets
./gen_env.sh --force # regenerate everything, including SECRET_KEY
```
It derives `TS_HOSTNAME` and `ALLOWED_HOSTS` from the tailnet hostname you
give it, and can set `CORS_ALLOWED_ORIGINS`/`CSRF_TRUSTED_ORIGINS` when you
provide the frontend's hostname for a split deployment. `--update` is the safe
way to add hostnames later; `--force` rotates SECRET_KEY, which invalidates
signed media URLs and stored e621 API keys. The file is written with mode 600
and is git-ignored.
## Usage ## Usage
```bash ```bash
cd deploy cd deploy
cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ... ./gen_env.sh # or: cp .env.example .env and edit it yourself
docker compose up -d # default: everything on one host docker compose up -d # default: everything on one host, public funnel
# or # or
docker compose -f compose.frontend.yml up -d docker compose -f compose.frontend.yml up -d
docker compose -f compose.backend.yml up -d docker compose -f compose.backend.yml up -d
# tailnet-only (no public funnel): compose.tailnet*.yml, see below
``` ```
The images are pulled from the Gitea registry; append `--build` (or run the The images are pulled from the Gitea registry; append `--build` (or run the
@@ -72,6 +123,23 @@ Push multi-arch images to the Gitea registry:
They tag `:latest` and `:<commit-sha>` and expect `docker login They tag `:latest` and `:<commit-sha>` and expect `docker login
gitea.rainbow-herring.ts.net` to succeed. gitea.rainbow-herring.ts.net` to succeed.
## Scheduled jobs
Compose files with a backend also run a **`scheduler`** service — the same
backend image with a different entrypoint, so no host cron is involved:
| Job | Default interval | Env override |
| --- | --- | --- |
| `sync_followed_tags` + `sync_followed_pools` | every 30 minutes | `J621_SYNC_EVERY` |
| `cleanup_similarity` | hourly | `J621_CLEAN_EVERY` |
| `refresh_guest_blacklist` | daily | `J621_BLACKLIST_EVERY` |
It waits for the database and migrations before its first run, runs every job
once on start, then keeps to the intervals (failures are logged and retried
next round). Output goes to `docker compose logs scheduler`. Intervals are
seconds, set in `deploy/.env`. The frontend-only composes have no backend, so
no scheduler.
## Tests ## Tests
The security/permission suite lives in `backend/apps/core/tests/`: The security/permission suite lives in `backend/apps/core/tests/`:
+27
View File
@@ -68,6 +68,33 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
# Periodic maintenance inside the deployment (no host cron): follow syncs,
# similarity cleanup and the guest blacklist refresh.
scheduler:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
entrypoint: ["j621-scheduler"]
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
volumes:
- ./data/media:/app/media
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
nginx: nginx:
image: nginx:1.29-alpine image: nginx:1.29-alpine
restart: unless-stopped restart: unless-stopped
+136
View File
@@ -0,0 +1,136 @@
# J621 — backend-only deployment: API + database + nginx proxy + Tailscale.
#
# cd deploy && cp .env.example .env # TS_AUTHKEY, SECRET_KEY, hosts, CORS
# docker compose -f compose.tailnet.backend.yml up -d
#
# Tailnet-only: https://<TS_HOSTNAME>.<tailnet>.ts.net:8443 reaches the
# nginx service from your tailnet, which routes /api, /admin, /static and /health to
# Django. "/" answers a small JSON hint because no frontend is attached.
#
# Because the frontend lives elsewhere it is cross-origin — set in .env:
# CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net
# CSRF_TRUSTED_ORIGINS=... (same value; only needed for the admin)
# and add this host to ALLOWED_HOSTS (comma separated list).
name: j621-backend-deploy-tailnet
services:
mariadb:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
MARIADB_DATABASE: ${DB_NAME:-j621}
MARIADB_USER: ${DB_USER:-j621}
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
volumes:
- ./data/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
# Bake the commit into the image so the shell's version pill shows it;
# the push scripts pass --build-arg themselves.
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
TRUST_PROXY_HEADERS: "true"
volumes:
- ./data/media:/app/media
- ./data/logs:/app/logs
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
# Periodic maintenance inside the deployment (no host cron): follow syncs,
# similarity cleanup and the guest blacklist refresh.
scheduler:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
entrypoint: ["j621-scheduler"]
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
volumes:
- ./data/media:/app/media
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
nginx:
image: nginx:1.29-alpine
restart: unless-stopped
volumes:
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
tailscale:
image: tailscale/tailscale:latest
restart: unless-stopped
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
network_mode: "service:nginx"
environment:
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
TS_HOSTNAME: ${TS_HOSTNAME:-j621-backend}
TS_AUTH_ONCE: "true"
TS_STATE_DIR: /var/lib/tailscale
TS_SERVE_CONFIG: /config/serve.json
volumes:
- ./tailscale-state:/var/lib/tailscale
- ./serve.backend.tailnet.json:/config/serve.json:ro
- /etc/ssl/certs:/etc/ssl/certs:ro
depends_on:
nginx:
condition: service_healthy
healthcheck:
test: ["CMD", "tailscale", "status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+63
View File
@@ -0,0 +1,63 @@
# J621 — frontend-only deployment: SPA + nginx proxy + Tailscale sidecar.
#
# cd deploy && cp .env.example .env # TS_AUTHKEY at minimum
# docker compose -f compose.tailnet.frontend.yml up -d
#
# Tailnet-only: https://<TS_HOSTNAME>.<tailnet>.ts.net (443) serves the SPA
# for devices on your tailnet; nothing is exposed publicly.
# On first start the SPA asks for a backend (/setup): point it at a
# backend-only deployment (e.g. https://j621-backend.<tailnet>.ts.net:8443),
# leave it blank to serve one yourself, or stay in local mode.
#
# There is no backend in this compose, so /api answers 502 here until the SPA
# is configured to call one elsewhere.
name: j621-frontend-deploy-tailnet
services:
frontend:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Frontend
restart: unless-stopped
nginx:
image: nginx:1.29-alpine
restart: unless-stopped
volumes:
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
frontend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
tailscale:
image: tailscale/tailscale:latest
restart: unless-stopped
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
network_mode: "service:nginx"
environment:
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
TS_HOSTNAME: ${TS_HOSTNAME:-j621-frontend}
TS_AUTH_ONCE: "true"
TS_STATE_DIR: /var/lib/tailscale
TS_SERVE_CONFIG: /config/serve.json
volumes:
- ./tailscale-state:/var/lib/tailscale
- ./serve.frontend.tailnet.json:/config/serve.json:ro
- /etc/ssl/certs:/etc/ssl/certs:ro
depends_on:
nginx:
condition: service_healthy
healthcheck:
test: ["CMD", "tailscale", "status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+144
View File
@@ -0,0 +1,144 @@
# J621 — default deployment: frontend + backend + database on one Tailscale
# host, behind the nginx proxy service (no host nginx, nothing published on
# the host's ports).
#
# cd deploy && cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ...
# docker compose up -d # or: docker compose -f compose.yml up -d
#
# Tailnet-only: no Funnel, so the service is reachable from your tailnet
# (https://<TS_HOSTNAME>.<tailnet>.ts.net) but not from the public internet.
# The nginx service routes /api, /admin, /static and /health to the
# backend and everything else to the SPA. Same origin, so no CORS needed.
name: j621-deploy-tailnet
services:
mariadb:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
MARIADB_DATABASE: ${DB_NAME:-j621}
MARIADB_USER: ${DB_USER:-j621}
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
volumes:
- ./data/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
# Bake the commit into the image so the shell's version pill shows it;
# the push scripts pass --build-arg themselves.
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
# The tailnet funnel terminates TLS and forwards the original host/proto.
TRUST_PROXY_HEADERS: "true"
volumes:
- ./data/media:/app/media
- ./data/logs:/app/logs
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
# Periodic maintenance inside the deployment (no host cron): follow syncs,
# similarity cleanup and the guest blacklist refresh.
scheduler:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
entrypoint: ["j621-scheduler"]
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
volumes:
- ./data/media:/app/media
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
frontend:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Frontend
restart: unless-stopped
nginx:
image: nginx:1.29-alpine
restart: unless-stopped
volumes:
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
tailscale:
image: tailscale/tailscale:latest
restart: unless-stopped
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
network_mode: "service:nginx"
environment:
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
TS_HOSTNAME: ${TS_HOSTNAME:-j621}
TS_AUTH_ONCE: "true"
TS_STATE_DIR: /var/lib/tailscale
TS_SERVE_CONFIG: /config/serve.json
volumes:
- ./tailscale-state:/var/lib/tailscale
- ./serve.default.tailnet.json:/config/serve.json:ro
- /etc/ssl/certs:/etc/ssl/certs:ro
depends_on:
nginx:
condition: service_healthy
healthcheck:
test: ["CMD", "tailscale", "status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+27
View File
@@ -66,6 +66,33 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
# Periodic maintenance inside the deployment (no host cron): follow syncs,
# similarity cleanup and the guest blacklist refresh.
scheduler:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
build:
context: ..
dockerfile: deploy/J621-Backend
args:
GIT_HASH: ${GIT_HASH:-unknown}
restart: unless-stopped
entrypoint: ["j621-scheduler"]
env_file: [./.env]
environment:
DB_HOST: mariadb
DB_PORT: "3306"
REDIS_URL: redis://redis:6379/1
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
volumes:
- ./data/media:/app/media
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
frontend: frontend:
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest} image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
build: build:
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
# Generate deploy/.env from .env.example with fresh secrets.
#
# ./gen_env.sh # interactive: asks for the auth key/hostnames
# ./gen_env.sh --no-prompt # secrets + defaults only
# ./gen_env.sh --update # refresh hostnames/authkey, KEEP existing secrets
# ./gen_env.sh --force # regenerate everything (new SECRET_KEY!)
#
# SECRET_KEY and the database passwords come from openssl. Rotating
# SECRET_KEY invalidates signed media URLs and stored e621 API keys, which is
# why --update keeps it.
set -euo pipefail
cd "$(dirname "$0")"
FORCE=0
UPDATE=0
NO_PROMPT=0
TS_AUTHKEY=""
FQDN=""
FRONTEND=""
DEFAULT_FQDN="j621.rainbow-herring.ts.net"
usage() {
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--force) FORCE=1 ;;
--update) UPDATE=1 ;;
--no-prompt) NO_PROMPT=1 ;;
--ts-authkey) TS_AUTHKEY="${2:?missing value}"; shift ;;
--hostname) FQDN="${2:?missing value}"; shift ;;
--frontend) FRONTEND="${2:?missing value}"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
command -v openssl >/dev/null || { echo "openssl is required." >&2; exit 1; }
command -v python3 >/dev/null || { echo "python3 is required." >&2; exit 1; }
[ -f .env.example ] || { echo "Run me from the deploy/ directory." >&2; exit 1; }
if [ -f .env ] && [ "$FORCE" -eq 0 ] && [ "$UPDATE" -eq 0 ]; then
echo "deploy/.env already exists."
echo " --update keeps the existing secrets and just refreshes the rest"
echo " --force regenerates everything, including SECRET_KEY and DB passwords"
exit 1
fi
existing() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; }
gen_key() { openssl rand -base64 48 | tr -d '\n'; }
gen_password() { openssl rand -hex 24; }
if [ "$UPDATE" -eq 1 ] && [ -f .env ]; then
SECRET_KEY="$(existing SECRET_KEY)"
DB_PASSWORD="$(existing DB_PASSWORD)"
DB_ROOT_PASSWORD="$(existing DB_ROOT_PASSWORD)"
TS_AUTHKEY="${TS_AUTHKEY:-$(existing TS_AUTHKEY)}"
# TS_HOSTNAME is the short label; the full FQDN lives in ALLOWED_HOSTS.
EXISTING_HOSTS="$(existing ALLOWED_HOSTS)"
FQDN="${FQDN:-${EXISTING_HOSTS%%,*}}"
fi
# Fill whatever is still missing.
SECRET_KEY="${SECRET_KEY:-$(gen_key)}"
DB_PASSWORD="${DB_PASSWORD:-$(gen_password)}"
DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-$(gen_password)}"
if [ "$NO_PROMPT" -eq 0 ]; then
if [ -z "$TS_AUTHKEY" ]; then
read -rp "Tailscale auth key (tskey-..., Enter to fill in later): " TS_AUTHKEY
fi
if [ -z "$FQDN" ]; then
read -rp "Tailnet hostname of this deployment [$DEFAULT_FQDN]: " FQDN
fi
FQDN="${FQDN:-$DEFAULT_FQDN}"
if [ -z "$FRONTEND" ]; then
read -rp "Frontend hostname for the split deploys (optional, Enter to skip): " FRONTEND
fi
fi
FQDN="${FQDN:-$DEFAULT_FQDN}"
# Derive the rest from the tailnet hostname.
TS_HOSTNAME="${FQDN%%.*}"
ALLOWED_HOSTS="$FQDN,localhost,127.0.0.1"
CORS_ALLOWED_ORIGINS="${FRONTEND:+https://$FRONTEND}"
CSRF_TRUSTED_ORIGINS="${FRONTEND:+https://$FRONTEND}"
J621_SECRET_KEY="$SECRET_KEY" \
J621_DB_PASSWORD="$DB_PASSWORD" \
J621_DB_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \
J621_TS_AUTHKEY="$TS_AUTHKEY" \
J621_TS_HOSTNAME="$TS_HOSTNAME" \
J621_ALLOWED_HOSTS="$ALLOWED_HOSTS" \
J621_CORS_ALLOWED_ORIGINS="$CORS_ALLOWED_ORIGINS" \
J621_CSRF_TRUSTED_ORIGINS="$CSRF_TRUSTED_ORIGINS" \
python3 - <<'PY'
import os
import re
from pathlib import Path
text = Path(".env.example").read_text()
def apply(name):
value = os.environ.get(f"J621_{name}")
if not value:
return text
line = f"{name}={value}"
pattern = re.compile(rf"^(?:# )?{re.escape(name)}=.*$", re.M)
if pattern.search(text):
return pattern.sub(line, text, count=1)
return text + f"\n{line}\n"
for name in (
"SECRET_KEY",
"TS_AUTHKEY",
"TS_HOSTNAME",
"ALLOWED_HOSTS",
"CORS_ALLOWED_ORIGINS",
"CSRF_TRUSTED_ORIGINS",
"DB_PASSWORD",
"DB_ROOT_PASSWORD",
):
text = apply(name)
text = re.sub(r"^DEBUG=.*$", "DEBUG=False", text, count=1, flags=re.M)
Path(".env").write_text(text)
PY
chmod 600 .env
echo
echo "Wrote deploy/.env (mode 600):"
echo " SECRET_KEY $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')"
echo " DB passwords $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')"
echo " TS_HOSTNAME $TS_HOSTNAME"
echo " ALLOWED_HOSTS $ALLOWED_HOSTS"
[ -n "$CORS_ALLOWED_ORIGINS" ] && echo " cross-origin $CORS_ALLOWED_ORIGINS"
[ -z "$TS_AUTHKEY" ] && echo " TS_AUTHKEY still empty - paste your Tailscale auth key before starting"
echo
echo "Next: docker compose -f compose.yml up -d (or a compose.tailnet*.yml variant)"
+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;
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Periodic maintenance for a J621 deployment (runs in the "scheduler"
# service; no host cron involved).
#
# sync_followed_tags + sync_followed_pools every SYNC_EVERY seconds (30 min)
# cleanup_similarity every CLEAN_EVERY seconds (1 h)
# refresh_guest_blacklist every BLACKLIST_EVERY (24 h)
#
# Waits for the database and migrations to be ready, runs everything once,
# then keeps checking. A failing command is logged and retried next interval,
# so a temporary e621 outage is not fatal.
set -u
SYNC_EVERY="${SYNC_EVERY:-1800}"
CLEAN_EVERY="${CLEAN_EVERY:-3600}"
BLACKLIST_EVERY="${BLACKLIST_EVERY:-86400}"
log() { echo "[scheduler] $(date -Iseconds) $*"; }
run() {
log "running: $*"
if "$@"; then
log "done: $*"
else
log "FAILED (retrying at the next interval): $*"
fi
}
command -v python >/dev/null || { log "python not found"; exit 1; }
log "waiting for the database and migrations..."
until python manage.py migrate --check >/dev/null 2>&1; do
sleep 10
done
log "database ready; intervals: sync=${SYNC_EVERY}s cleanup=${CLEAN_EVERY}s blacklist=${BLACKLIST_EVERY}s"
now=$(date +%s)
last_sync=$((now - SYNC_EVERY))
last_clean=$((now - CLEAN_EVERY))
last_blacklist=$((now - BLACKLIST_EVERY))
while true; do
now=$(date +%s)
if [ $((now - last_sync)) -ge "$SYNC_EVERY" ]; then
last_sync=$now
run python manage.py sync_followed_tags
run python manage.py sync_followed_pools
fi
if [ $((now - last_clean)) -ge "$CLEAN_EVERY" ]; then
last_clean=$now
run python manage.py cleanup_similarity
fi
if [ $((now - last_blacklist)) -ge "$BLACKLIST_EVERY" ]; then
last_blacklist=$now
run python manage.py refresh_guest_blacklist
fi
sleep 30
done
+16
View File
@@ -0,0 +1,16 @@
{
"TCP": {
"8443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:8443": {
"Handlers": {
"/": {
"Proxy": "http://127.0.0.1:80"
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": {
"Proxy": "http://127.0.0.1:80"
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": {
"Proxy": "http://127.0.0.1:80"
}
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
# fish_greeting (updated for the J621 rewrite)
Shows a random library image as your shell greeting, using fastfetch. This is
the updated version of the script from
`J621-Django/extras/fish_greeting system/fish_greeting.fish`, adapted to the
new REST API.
## What changed from the old script
| | Old J621-Django | This version |
| --- | --- | --- |
| Endpoint | `GET /random/?rating=X` returned **image bytes** | `GET /api/random/?fastfetch=1&rating=X` returns **JSON** |
| Image access | same response, `X-File-MD5`/`X-File-Name` headers | signed `url` from the JSON, downloaded separately |
| Unsupported types | rolled again (recursion) | server only returns png/jpg/gif in fastfetch mode |
| Config | hardcoded `https://j621.jake.i` | `J621_BASE` / `J621_TOKEN` / `J621_WEB` |
| Printed link | `/view/<md5>` on the old host | `/detail/<J-ID>` on `J621_WEB` |
| Modes, logging, gifsicle, fastfetch flags | same | same (`~/.config/fish/greeting_mode`, `~/.config/j621Logos/logs.log`) |
The `gm-switch` helper and the `.desktop` launchers from the old repo keep
working unchanged: they write the same `~/.config/fish/greeting_mode` file
(0 = NSFW, 1 = SFW, 2 = Questionable).
## Install
```fish
cd extras/fish_greeting
fish install.fish
```
The installer copies the function into `~/.config/fish/functions/`, **asks for
your API origin** (and an optional scoped token — see below), writes
`~/.config/j621Greeting/config.fish` (mode 600, it may hold the token), checks
the tools it needs and then verifies the backend: `/health` must answer and a
random roll is attempted. It exits non-zero when the backend cannot be
reached.
It also appends a guarded block to `~/.config/fish/config.fish` that sources
the function. That is needed on setups whose distro/theme config (CachyOS,
Oh My Fish, hand-rolled `config.fish`) defines `fish_greeting` inline while
the shell starts — such a definition wins over autoloading from
`functions/`, so ours has to be loaded afterwards. The block is written once
and repeated installs leave it alone; pass `--no-config` to skip it.
Non-interactive / re-install:
```fish
fish install.fish --url https://j621.example.ts.net --token <api-token>
fish install.fish --no-prompt # defaults, never asks
fish install.fish --force # rewrite an existing config
```
Then test:
```fish
fish_greeting
```
Requirements: `curl` (or `wget`), `fastfetch`, `file`. Optional: `gifsicle`
(GIF downscaling), `jq` or `python3` (JSON parsing — without either it falls
back to grep/sed).
## No token? That is fine
The greeting is meant to run **without** a token: it then behaves like a
guest of your instance — unsigned image links and only items that are visible
to guests. Adding a token to the config unlocks signed links (useful when
something else fetches the URL for you) and items that are hidden from
guests.
## Configuration
Any of these work; the config file is read by the function on every run:
```fish
# ~/.config/j621Greeting/config.fish, or universal variables
set -g J621_BASE https://j621.rainbow-herring.ts.net # API origin
set -g J621_WEB https://j621.example.ts.net # link origin (split deploys)
set -g J621_TOKEN <api token> # signed URLs + hidden items
set -g J621_FASTFETCH_CONFIG jake # fastfetch config name
```
`J621_TOKEN` is optional. The recommended value is a **scoped greeting
token**: open the app, go to *Account → Shell tokens* (or `/tokens`), create
one and copy the `j621r_…` key — it only works with `/api/random/`, so it is
safe to keep in this config. It also gives you signed image URLs and access
to items that are hidden from guests. Without a token the greeting runs as a
guest and sees the public library only.
## Rating filters
| `greeting_mode` | Rating requested | Label |
| --- | --- | --- |
| `0` | `e` | NSFW |
| `1` | `s` | SFW |
| `2` (default) | `q` | Questionable |
## Troubleshooting
- **The distro/theme greeting still shows** (CachyOS, Oh My Fish, …): those
configs define `fish_greeting` while the shell starts, which beats
autoloading. Check what fish resolved:
`functions --details fish_greeting` — it must point at
`~/.config/fish/functions/fish_greeting.fish`. If it points at a distro
file, run `fish install.fish` again (it adds the source block to
`config.fish`) or add it yourself:
```fish
if test -f ~/.config/fish/functions/fish_greeting.fish
source ~/.config/fish/functions/fish_greeting.fish
end
```
at the **end** of `~/.config/fish/config.fish`.
- `No logo (No image matches those filters.)` — the library has no images for
that rating; try another mode or add files.
- `No logo (API error)` — check `J621_BASE`, and that the deployment is up
(`https://<host>/health`).
- `No logo (download failed)` — the signed URL expired (they last 24 h) or the
item was deleted between the two requests; just run it again.
- The greeting is slow — the API and image fetch have `--max-time` guards; a
slow tailnet link is usually the cause.
+19
View File
@@ -0,0 +1,19 @@
# Copy to ~/.config/j621Greeting/config.fish and adjust. All of these can also
# be universal variables, e.g. `set -Ux J621_BASE https://j621.example.ts.net`.
# API origin (no trailing slash needed).
set -g J621_BASE https://j621.rainbow-herring.ts.net
# Web origin used in the printed link. Only needed when the SPA is served
# from a different host than the API (split deployment).
# set -g J621_WEB https://j621-frontend.rainbow-herring.ts.net
# API token. Optional: gives you signed image URLs and access to items that
# are hidden from guests. Use a scoped greeting token (Account -> Shell
# tokens, or /tokens in the app): those only work with /api/random/, so they
# are safe to keep in this file. The full API token works too, but grants
# everything.
# set -g J621_TOKEN paste-your-token-here
# fastfetch config name used for the greeting logo.
# set -g J621_FASTFETCH_CONFIG jake
+219
View File
@@ -0,0 +1,219 @@
# fish_greeting — show a random library image as the shell greeting.
#
# Updated for the J621 Docker/REST rewrite: the API answers with JSON that
# carries a signed URL instead of streaming the image, so this function asks
# for one random png/jpg/gif (fastfetch mode), downloads the signed link and
# hands the file to fastfetch. Based on the original script from
# J621-Django/extras/fish_greeting system/fish_greeting.fish.
#
# Requires: curl (or wget) and fastfetch; gifsicle is used for animated GIFs
# (without it, the GIF is shown as-is).
#
# Rating modes (same file as the original script):
# ~/.config/fish/greeting_mode 0 = NSFW (explicit), 1 = SFW (safe),
# 2 = Questionable (default)
#
# Configuration, any of these (all optional):
# ~/.config/j621Greeting/config.fish with `set -g VAR value` lines, or
# universal variables, e.g. `set -Ux J621_BASE https://j621.example.ts.net`
# J621_BASE API origin (default https://j621.rainbow-herring.ts.net)
# J621_TOKEN API token, sent as `Authorization: Token …`; needed for
# signed URLs and for hidden-from-guests items (optional)
# J621_WEB Web origin used in the printed link (defaults to J621_BASE)
# J621_FASTFETCH_CONFIG fastfetch config name (default "jake")
function __j621_json_field --argument-names json field
if command -v jq >/dev/null 2>&1
printf '%s' "$json" | jq -r --arg field "$field" '.[$field] // empty'
else if command -v python3 >/dev/null 2>&1
printf '%s' "$json" | python3 -c "import json,sys; value = json.load(sys.stdin).get(sys.argv[1], ''); print(value if value is not None else '')" $field
else
printf '%s' "$json" | grep -o "\"$field\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*:[[:space:]]*"//; s/"$//'
end
end
function __j621_fetch_random --argument-names rating
set base (string trim --right --chars=/ "$J621_BASE")
set api_url "$base/api/random/?fastfetch=1&rating=$rating"
set curl_args -s -L --max-time 20
if set -q J621_TOKEN; and test -n "$J621_TOKEN"
set -a curl_args -H "Authorization: Token $J621_TOKEN"
end
if command -v curl >/dev/null 2>&1
curl $curl_args "$api_url" | string collect
else if command -v wget >/dev/null 2>&1
wget -qO- "$api_url" | string collect
end
end
function fish_greeting --argument-names depth
# Initialize depth to 0 if not provided or empty
if not set -q depth; or test -z "$depth"
set depth 0
end
set MAX_DEPTH 3
# --- Configuration ---
set config_file ~/.config/j621Greeting/config.fish
if test -f $config_file
source $config_file
end
set -q J621_BASE; or set -g J621_BASE https://j621.rainbow-herring.ts.net
set -q J621_WEB; or set -g J621_WEB $J621_BASE
set -q J621_FASTFETCH_CONFIG; or set -g J621_FASTFETCH_CONFIG jake
set web_base (string trim --right --chars=/ "$J621_WEB")
# --- Setup logging ---
set log_dir ~/.config/j621Logos
if not test -d $log_dir
mkdir -p $log_dir
end
set log_file $log_dir/logs.log
# --- Mode selection ---
if test -f ~/.config/fish/greeting_mode
set mode (cat ~/.config/fish/greeting_mode | string trim)
else
set mode 2
end
switch $mode
case "0"
set rating e
set modename "NSFW"
case "1"
set rating s
set modename "SFW"
case "2"
set rating q
set modename "Questionable"
case "*"
echo "Unknown mode, using default NSFW"
set rating e
set modename "NSFW"
end
# --- Ask the API for a random image (JSON with a signed URL) ---
set json (__j621_fetch_random $rating)
set image_url (__j621_json_field "$json" url)
set j_id (__j621_json_field "$json" j_id)
set md5 (__j621_json_field "$json" md5)
set filename (__j621_json_field "$json" filename)
if test -z "$image_url"
set detail (__j621_json_field "$json" detail)
fastfetch --config "$J621_FASTFETCH_CONFIG"
if test -n "$detail"
printf '\e[4;2;38;2;138;0;222;49mNo logo (%s)\e[0m\n' "$detail"
else
printf '\e[4;2;38;2;138;0;222;49mNo logo (API error)\e[0m\n'
end
echo "$(date '+%Y-%m-%d %H:%M:%S') No logo for rating=$rating: $detail" >> "$log_file"
return
end
# --- Download the signed image ---
set tempfile (mktemp /tmp/fastfetch_logo_XXXXXX)
set download_success 0
if command -v curl >/dev/null 2>&1
curl -s -L --max-time 60 -o "$tempfile" "$image_url"
if test $status -eq 0 -a -s "$tempfile"
set download_success 1
end
else if command -v wget >/dev/null 2>&1
wget -q -O "$tempfile" "$image_url"
if test $status -eq 0 -a -s "$tempfile"
set download_success 1
end
end
if test $download_success -ne 1
fastfetch --config "$J621_FASTFETCH_CONFIG"
printf '\e[4;2;38;2;138;0;222;49mNo logo (download failed)\e[0m\n'
echo "$(date '+%Y-%m-%d %H:%M:%S') Download failed for $j_id" >> "$log_file"
rm -f "$tempfile"
return
end
# --- Determine MIME type ---
set mime (file --mime-type -b "$tempfile" 2>/dev/null | string trim | string collect)
# --- Accept only PNG, JPEG, GIF; everything else causes one more roll ---
if not string match -q "image/png" "$mime"; and not string match -q "image/jpeg" "$mime"; and not string match -q "image/gif" "$mime"
if test $depth -lt $MAX_DEPTH
fish_greeting (math $depth + 1)
else
printf '\e[31mMax recursion depth reached, skipping unsupported file type: %s\e[0m\n' "$mime"
end
if test $depth -eq 0
printf '\e[31mForked (Got unsupported type: %s - %s)\e[0m\n' "$mime" "$filename"
if test -n "$md5"
echo "Link: $web_base/view/$md5"
end
end
rm -f "$tempfile" "$tempfile.tmp" 2>/dev/null
return
end
# --- Process the file (only PNG, JPEG, GIF reach here) ---
set logo_type "kitty"
set processing_success 1
switch "$mime"
case "image/png" "image/jpeg"
set logo_type "kitty"
case "image/gif"
set processed_ok 0
set simple_file (mktemp /tmp/fastfetch_simple_XXXXXX)
set gif_err (mktemp)
if command -v gifsicle >/dev/null 2>&1
gifsicle --colors 255 "$tempfile" > "$simple_file" 2> "$gif_err"
set gif_exit $status
if test $gif_exit -eq 0 -a -s "$simple_file"
gifsicle --unoptimize --resize-fit 360x360 "$simple_file" > "$tempfile.tmp" 2> "$gif_err"
set final_exit $status
set gif_warnings (cat "$gif_err" | string trim)
if test $final_exit -eq 0 -a -s "$tempfile.tmp" -a -z "$gif_warnings"
mv "$tempfile.tmp" "$tempfile"
set processed_ok 1
set logo_type "kitty-icat"
echo "$(date '+%Y-%m-%d %H:%M:%S') GIF processed with gifsicle" >> "$log_file"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') gifsicle final failed: $gif_warnings" >> "$log_file"
end
else
echo "$(date '+%Y-%m-%d %H:%M:%S') gifsicle --colors failed" >> "$log_file"
end
end
rm -f "$simple_file" "$gif_err"
if test $processed_ok -eq 0
set logo_type "kitty-icat"
echo "$(date '+%Y-%m-%d %H:%M:%S') Using original GIF (processing failed)" >> "$log_file"
end
end
# --- Display result ---
if test $processing_success -eq 1
fastfetch --config "$J621_FASTFETCH_CONFIG" --logo-type "$logo_type" --logo "$tempfile" --logo-width 35
set timestamp (date '+%Y-%m-%d %H:%M:%S')
echo "$timestamp Downloaded: $j_id $filename [$modename]" >> "$log_file"
if test -n "$filename"
printf '\e[4;2;38;2;138;0;222;49mLogo from API: %s (%s, %s)\e[0m\n' "$filename" "$modename" "$j_id"
echo "Link: $web_base/detail/$j_id"
else
printf '\e[4;2;38;2;138;0;222;49mLogo from API (%s)\e[0m\n' "$modename"
end
else
fastfetch --config "$J621_FASTFETCH_CONFIG"
printf '\e[4;2;38;2;138;0;222;49mNo logo (Image processing error)\e[0m\n'
end
# --- Keep only the last 1000 lines of the log ---
set log_tmp (mktemp)
tail -n 1000 "$log_file" > "$log_tmp" 2>/dev/null
mv "$log_tmp" "$log_file"
# --- Cleanup ---
rm -f "$tempfile" "$tempfile.tmp" 2>/dev/null
end
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env fish
# Install the J621 fish greeting:
# * copies fish_greeting.fish into ~/.config/fish/functions/
# * writes ~/.config/j621Greeting/config.fish (asks for the API origin and an
# optional token, or takes them as flags)
# * checks the tools it needs and probes the configured backend
#
# Usage:
# fish install.fish
# fish install.fish --url https://j621.example.ts.net --token <api-token>
# fish install.fish --no-prompt # never ask, keep/derive defaults
# fish install.fish --force # rewrite an existing config
# fish install.fish --no-config # do not touch ~/.config/fish/config.fish
argparse 'url=' 'token=' 'no-prompt' 'force' 'no-config' 'help' -- $argv
or begin
echo "Try: fish install.fish --help"
exit 1
end
if set -q _flag_help
sed -n '2,12p' (status --current-filename) | sed 's/^# \{0,1\}//'
exit 0
end
set -l here (path resolve (dirname (status --current-filename)))
set -l functions_dir ~/.config/fish/functions
set -l config_dir ~/.config/j621Greeting
set -l config_file $config_dir/config.fish
set -l default_url https://j621.rainbow-herring.ts.net
set -l url $default_url
set -l token ""
set -l problems 0
if set -q _flag_url
set url $_flag_url
end
if set -q _flag_token
set token $_flag_token
end
# --- Install the function -----------------------------------------------------
mkdir -p $functions_dir
cp "$here/fish_greeting.fish" "$functions_dir/fish_greeting.fish"
echo "Installed $functions_dir/fish_greeting.fish"
# --- Override distro/OMF greetings -------------------------------------------
# Some setups (CachyOS, OMF themes, hand-rolled configs) define fish_greeting
# inline while config.fish is being read. A function defined that way wins over
# autoloading, so our file would never load. Source it from the end of
# config.fish to define ours last.
set -l marker "# >>> J621 greeting >>>"
set -l user_config ~/.config/fish/config.fish
if set -q _flag_no_config
echo "Skipping $user_config (--no-config)"
else if test -f $user_config; and grep -qF "$marker" $user_config
echo "Config already sources the greeting (marker present)"
else
mkdir -p (dirname $user_config)
begin
echo ""
echo "$marker"
echo "# Loaded after the distro config so it wins over an inline fish_greeting."
echo "if test -f $functions_dir/fish_greeting.fish"
echo " source $functions_dir/fish_greeting.fish"
echo "end"
echo "# <<< J621 greeting <<<"
end >> $user_config
echo "Added the J621 greeting to $user_config (removes any distro greeting override)"
end
# --- Configuration ------------------------------------------------------------
mkdir -p $config_dir
set -l write_config 0
if not test -f $config_file
set write_config 1
else if set -q _flag_force; or set -q _flag_url
set write_config 1
else
echo "Keeping existing $config_file (use --force to rewrite it)"
# Read the current origin back so the probe below uses it.
set -l current (grep -E '^set -g J621_BASE ' $config_file 2>/dev/null | head -1 | string replace -r '^set -g J621_BASE +' '')
test -n "$current"; and set url $current
end
if test $write_config -eq 1
if not set -q _flag_no_prompt
if not set -q _flag_url
read -P "API origin [$default_url]: " answer
test -n "$answer"; and set url $answer
end
if not set -q _flag_token
read -P "API token (optional, Enter to run as a guest): " answer
test -n "$answer"; and set token $answer
end
end
set url (string trim --right --chars=/ "$url")
printf '# Written by install.fish on %s\n' (date '+%Y-%m-%d') > $config_file
printf 'set -g J621_BASE %s\n' "$url" >> $config_file
if test -n "$token"
printf 'set -g J621_TOKEN %s\n' "$token" >> $config_file
else
printf '# set -g J621_TOKEN paste-a-token-here\n' >> $config_file
end
printf '# set -g J621_WEB %s\n' "$url" >> $config_file
printf '# set -g J621_FASTFETCH_CONFIG jake\n' >> $config_file
chmod 600 $config_file
echo "Wrote $config_file"
end
# --- Dependencies -------------------------------------------------------------
for tool in fastfetch file
if not command -v $tool >/dev/null 2>&1
set problems 1
echo "Missing required tool: $tool"
end
end
if not command -v curl >/dev/null 2>&1; and not command -v wget >/dev/null 2>&1
set problems 1
echo "Missing required tool: curl (or wget)"
end
for tool in gifsicle jq python3
if not command -v $tool >/dev/null 2>&1
echo "Optional tool not found: $tool (the greeting still works)"
end
end
# --- Probe the configured backend --------------------------------------------
echo
echo "Checking $url ..."
set -l health ""
if command -v curl >/dev/null 2>&1
set health (curl -s -m 10 "$url/health" | string collect)
else
set health (wget -qO- -T 10 "$url/health" | string collect)
end
if string match -q '*"status":"ok"*' "$health"
echo " backend: ok"
set -l random_args -s -m 10
if test -n "$token"
set -a random_args -H "Authorization: Token $token"
end
set -l probe ""
if command -v curl >/dev/null 2>&1
set probe (curl $random_args "$url/api/random/?fastfetch=1" | string collect)
else
set probe (wget -qO- -T 10 "$url/api/random/?fastfetch=1" | string collect)
end
set -l detail (printf '%s' "$probe" | grep -o '"detail"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"//; s/"$//')
if test -n "$detail"
echo " random: $detail"
if test -z "$token"
echo " (a token would also let you see items that are hidden from guests)"
end
else
echo " random: ok"
end
else
set problems 1
echo " backend did not answer at $url/health"
echo " got: $health"
echo " Fix J621_BASE in $config_file (or pass --url) and run again."
end
# --- Wrap up ------------------------------------------------------------------
echo
if test $problems -eq 0
echo "Done. Test it now with: fish_greeting"
else
echo "Finished with problems above; the greeting will report them too."
end
echo "Rating mode lives in ~/.config/fish/greeting_mode (0=NSFW, 1=SFW, 2=Questionable)."
echo "The old gm-switch tool / .desktop launchers keep working — same file."
exit $problems
+20
View File
@@ -23,9 +23,11 @@ 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";
import TokensPage from "@/features/tokens/TokensPage";
import UploadPage from "@/features/upload/UploadPage"; import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage"; import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age"; import { isAgeVerified, markAgeVerified } from "@/lib/age";
@@ -99,6 +101,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 />} />
@@ -174,6 +184,16 @@ export default function App() {
} }
/> />
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route
path="/tokens"
element={
<RequireBackend>
<RequireAuth>
<TokensPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
<Route <Route
+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,11 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Cable, Cable,
Copy, Copy,
Dices,
ExternalLink, ExternalLink,
FolderOpen, FolderOpen,
Globe, Globe,
History, History,
Images, Images,
KeyRound,
Layers, Layers,
LogIn, LogIn,
LogOut, LogOut,
@@ -124,6 +126,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"),
},
] ]
: []), : []),
{ {
@@ -213,6 +221,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Settings, icon: Settings,
run: () => navigate("/account"), run: () => navigate("/account"),
}); });
list.push({
id: "tokens",
label: "API tokens",
icon: KeyRound,
run: () => navigate("/tokens"),
});
if (user.is_staff || user.is_superuser || user.role === "staff") { if (user.is_staff || user.is_superuser || user.role === "staff") {
list.push({ list.push({
id: "users", id: "users",
@@ -17,6 +17,7 @@ import { toast } from "@/store/toasts";
import { AvatarCard } from "./AvatarCard"; import { AvatarCard } from "./AvatarCard";
import { PreferencesCard } from "./PreferencesCard"; import { PreferencesCard } from "./PreferencesCard";
import { TokensCard } from "./TokensCard";
const BASE_URL_OPTIONS = [ const BASE_URL_OPTIONS = [
{ value: "https://e621.net", label: "e621.net — main site" }, { value: "https://e621.net", label: "e621.net — main site" },
@@ -247,6 +248,7 @@ export default function AccountPage() {
</header> </header>
<AvatarCard /> <AvatarCard />
<PreferencesCard /> <PreferencesCard />
<TokensCard />
{loading && !credentials ? ( {loading && !credentials ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<Spinner className="h-6 w-6" /> <Spinner className="h-6 w-6" />
@@ -0,0 +1,23 @@
import { Link } from "react-router-dom";
import { linkButtonClass } from "@/components/ui";
export function TokensCard() {
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Shell tokens
</h2>
<p className="mt-1 text-xs leading-relaxed text-ctp-overlay0">
Long-lived tokens that only work with the random-image endpoint — for
shell greetings and small scripts. They cannot read the library,
upload or change your account.
</p>
<div className="mt-3">
<Link to="/tokens" className={linkButtonClass}>
Manage API tokens
</Link>
</div>
</section>
);
}
+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>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { GreetingToken, GreetingTokenCreated } from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
export default function TokensPage() {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [fresh, setFresh] = useState<GreetingTokenCreated | null>(null);
const query = useQuery({
queryKey: ["greeting-tokens"],
queryFn: () => api<GreetingToken[]>("/api/auth/greeting-tokens/"),
});
const createMutation = useMutation({
mutationFn: () =>
api<GreetingTokenCreated>("/api/auth/greeting-tokens/", {
method: "POST",
json: { label: label.trim() },
}),
onSuccess: (created) => {
setFresh(created);
setLabel("");
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const revokeMutation = useMutation({
mutationFn: (id: number) =>
api(`/api/auth/greeting-tokens/${id}/`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
toast.ok("Token revoked.");
},
onError: (error) => toast.error(errorMessage(error)),
});
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
toast.ok(`${what} copied.`);
} catch {
toast.error("Could not copy — select the text and copy it manually.");
}
}
async function revoke(token: GreetingToken) {
const confirmed = await confirmAction({
title: `Revoke ${token.prefix}…?`,
description:
"Whatever uses this token (your shell greeting, a script) stops working immediately. You can create a new one any time.",
confirmLabel: "Revoke",
danger: true,
});
if (confirmed) revokeMutation.mutate(token.id);
}
const tokens = query.data ?? [];
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">API tokens</h1>
<p className="mt-1 text-sm leading-relaxed text-ctp-overlay0">
Tokens that only work with the random-image endpoint
(<span className="font-mono">/api/random/</span>) — made for shell
greetings and little scripts. They cannot read the library, upload,
delete or change your account, and only a hash is stored on the
server.
</p>
</header>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Create a token
</h2>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className={cn(inputClass, "max-w-xs flex-1")}
placeholder="Label, e.g. laptop greeting"
value={label}
onChange={(event) => setLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !createMutation.isPending) {
createMutation.mutate();
}
}}
/>
<Button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<KeyRound className="h-4 w-4" />
{createMutation.isPending ? "Creating…" : "Create token"}
</Button>
</div>
{fresh ? (
<div className="mt-4 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-3">
<p className="text-xs font-medium text-ctp-green">
Copy this key now — it is not shown again.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded bg-ctp-crust px-2 py-1.5 font-mono text-xs text-ctp-text">
{fresh.key}
</code>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() => void copy(fresh.key, "Token")}
>
<Copy className="h-3.5 w-3.5" /> Copy
</Button>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() =>
void copy(`set -g J621_TOKEN ${fresh.key}`, "fish line")
}
>
<Copy className="h-3.5 w-3.5" /> Copy for fish
</Button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-ctp-overlay0">
Shell greetings: put that line in{" "}
<span className="font-mono">
~/.config/j621Greeting/config.fish
</span>{" "}
(see <span className="font-mono">extras/fish_greeting</span> in
the repository).
</p>
</div>
) : null}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Your tokens
</h2>
{query.isPending ? (
<div className="flex justify-center py-12">
<Spinner className="h-5 w-5" />
</div>
) : query.isError ? (
<EmptyState
title="Could not load tokens"
description={errorMessage(query.error)}
/>
) : tokens.length === 0 ? (
<EmptyState
title="No tokens yet"
description="Create one above to use the random endpoint from a script."
/>
) : (
<ul className="flex flex-col gap-2">
{tokens.map((token) => (
<li
key={token.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-ctp-surface0 bg-ctp-base px-3 py-2"
>
<span className="font-mono text-xs text-ctp-subtext1">
{token.prefix}…
</span>
<span className="text-xs text-ctp-subtext0">
{token.label || "no label"}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
created {formatDate(token.created_at)}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
{token.last_used_at
? `last used ${formatDate(token.last_used_at)}`
: "never used"}
</span>
<Button
variant="ghost"
className="ml-auto px-2 py-1 text-xs text-ctp-red hover:bg-ctp-red/15"
disabled={revokeMutation.isPending}
onClick={() => void revoke(token)}
title="Revoke this token"
>
<Trash2 className="h-3.5 w-3.5" /> Revoke
</Button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+28
View File
@@ -1,3 +1,31 @@
export interface GreetingToken {
id: number;
prefix: string;
label: string;
created_at: string;
last_used_at: string | null;
}
export interface GreetingTokenCreated extends GreetingToken {
/** Only ever returned by the creation request. */
key: string;
}
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[];