Compare commits
10
Commits
ce016fb221
...
1170e6e9c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1170e6e9c1 | ||
|
|
b3ceac52ed | ||
|
|
bcff184a64 | ||
|
|
770b1e5ee6 | ||
|
|
2d9493d9fe | ||
|
|
aee29de34a | ||
|
|
f8667c1037 | ||
|
|
f25526782c | ||
|
|
93cce6b9fd | ||
|
|
30b1a1a4b0 |
@@ -25,6 +25,7 @@ frontend/dist/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.directory
|
||||||
|
|
||||||
# Tooling
|
# Tooling
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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'@'%';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
@@ -433,6 +434,28 @@ class RemoteUrlTests(SecurityTestCase):
|
|||||||
"https://static1.e621.net/data/x.png",
|
"https://static1.e621.net/data/x.png",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mock.patch("requests.get")
|
||||||
|
def test_redirects_off_the_allowlist_are_refused(self, mocked_get):
|
||||||
|
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
|
||||||
|
redirect.headers = {"Location": "http://127.0.0.1:8000/health"}
|
||||||
|
mocked_get.return_value = redirect
|
||||||
|
|
||||||
|
with self.assertRaises(services.RemoteUrlError):
|
||||||
|
services.open_remote("https://static1.e621.net/x.png")
|
||||||
|
# The internal address was never requested: only the first hop was.
|
||||||
|
self.assertEqual(mocked_get.call_count, 1)
|
||||||
|
redirect.close.assert_called()
|
||||||
|
|
||||||
|
@mock.patch("requests.get")
|
||||||
|
def test_redirects_within_the_allowlist_are_followed(self, mocked_get):
|
||||||
|
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
|
||||||
|
redirect.headers = {"Location": "https://static2.e621.net/x.png"}
|
||||||
|
final = mock.Mock(is_redirect=False, is_permanent_redirect=False)
|
||||||
|
mocked_get.side_effect = [redirect, final]
|
||||||
|
|
||||||
|
self.assertIs(services.open_remote("https://static1.e621.net/x.png"), final)
|
||||||
|
self.assertEqual(mocked_get.call_count, 2)
|
||||||
|
|
||||||
def test_download_creation_rejects_internal_urls(self):
|
def test_download_creation_rejects_internal_urls(self):
|
||||||
uploader = self.client_for("sec-uploader")
|
uploader = self.client_for("sec-uploader")
|
||||||
for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"):
|
for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"):
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Tests for the random image endpoint (`/api/random/`, `/random`).
|
||||||
|
|
||||||
|
Used by the SPA's Random page and by shell greeting scripts (fish_greeting
|
||||||
|
with fastfetch), so the response contract matters:
|
||||||
|
* JSON with an absolute, directly fetchable URL,
|
||||||
|
* signed for authenticated callers (image viewers send no headers),
|
||||||
|
* fastfetch mode restricted to png/jpg/gif,
|
||||||
|
* rating filters and guest visibility applied server-side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from apps.library.models import MediaItem, MediaLocation
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "apng"}
|
||||||
|
FASTFETCH_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
|
||||||
|
|
||||||
|
|
||||||
|
class RandomItemTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls._tmp = tempfile.mkdtemp(prefix="j621-random-")
|
||||||
|
cls._watched = Path(cls._tmp) / "library"
|
||||||
|
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||||
|
cls._settings = override_settings(
|
||||||
|
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||||
|
)
|
||||||
|
cls._settings.enable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls._settings.disable()
|
||||||
|
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="random-user", password="random-pass-123456"
|
||||||
|
)
|
||||||
|
token = Token.objects.create(user=self.user)
|
||||||
|
self.authed = Client()
|
||||||
|
self.authed.defaults["HTTP_AUTHORIZATION"] = f"Token {token.key}"
|
||||||
|
self.guest = Client()
|
||||||
|
|
||||||
|
def make_item(self, label, extension, rating, *, hidden=False):
|
||||||
|
path = self._watched / f"{label}.{extension}"
|
||||||
|
path.write_bytes(b"random-" + label.encode())
|
||||||
|
item = MediaItem.objects.create(
|
||||||
|
md5=hashlib.md5(label.encode()).hexdigest(),
|
||||||
|
size=path.stat().st_size,
|
||||||
|
rating=rating,
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
MediaLocation.objects.create(
|
||||||
|
item=item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||||
|
)
|
||||||
|
if hidden:
|
||||||
|
MediaItem.objects.filter(pk=item.pk).update(hidden_from_guests=True)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def test_returns_image_with_signed_absolute_url(self):
|
||||||
|
item = self.make_item("plain", "png", "s")
|
||||||
|
response = self.authed.get("/api/random/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["j_id"], f"J-{item.id}")
|
||||||
|
self.assertEqual(data["extension"], "png")
|
||||||
|
self.assertEqual(data["kind"], "image")
|
||||||
|
self.assertEqual(data["rating"], "s")
|
||||||
|
self.assertTrue(data["url"].startswith("http"))
|
||||||
|
self.assertIn("sig=", data["url"])
|
||||||
|
self.assertIn("download=1", data["download_url"])
|
||||||
|
self.assertFalse(data["fastfetch"])
|
||||||
|
|
||||||
|
def test_guest_url_is_unsigned_and_still_serves(self):
|
||||||
|
self.make_item("guest", "jpg", "s")
|
||||||
|
data = self.guest.get("/api/random/").json()
|
||||||
|
self.assertNotIn("sig=", data["url"])
|
||||||
|
path = data["url"].replace("http://testserver", "")
|
||||||
|
self.assertEqual(self.guest.get(path).status_code, 200)
|
||||||
|
|
||||||
|
def test_default_mode_returns_images_only(self):
|
||||||
|
self.make_item("movie", "mp4", "s")
|
||||||
|
self.make_item("picture", "webp", "s")
|
||||||
|
for _ in range(10):
|
||||||
|
extension = self.authed.get("/api/random/").json()["extension"]
|
||||||
|
self.assertIn(extension, IMAGE_EXTENSIONS)
|
||||||
|
|
||||||
|
def test_fastfetch_mode_flag_and_user_agent_restrict_formats(self):
|
||||||
|
self.make_item("movie", "mp4", "s")
|
||||||
|
self.make_item("modern", "webp", "s")
|
||||||
|
self.make_item("picture", "png", "s")
|
||||||
|
self.make_item("animation", "gif", "s")
|
||||||
|
|
||||||
|
attempts = [("flag", {"fastfetch": "1"}, {}), ("ua", {}, {"HTTP_USER_AGENT": "fastfetch/2.18.1"})]
|
||||||
|
for label, params, headers in attempts:
|
||||||
|
for _ in range(15):
|
||||||
|
response = self.authed.get("/api/random/", params, **headers)
|
||||||
|
self.assertEqual(response.status_code, 200, label)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn(data["extension"], FASTFETCH_EXTENSIONS, label)
|
||||||
|
self.assertTrue(data["fastfetch"], label)
|
||||||
|
|
||||||
|
def test_rating_filter(self):
|
||||||
|
self.make_item("safe", "png", "s")
|
||||||
|
explicit = self.make_item("explicit", "png", "e")
|
||||||
|
for _ in range(10):
|
||||||
|
data = self.authed.get("/api/random/", {"rating": "e"}).json()
|
||||||
|
self.assertEqual(data["j_id"], f"J-{explicit.id}")
|
||||||
|
self.assertEqual(data["rating"], "e")
|
||||||
|
self.assertEqual(self.authed.get("/api/random/", {"rating": "q"}).status_code, 404)
|
||||||
|
|
||||||
|
def test_guests_never_receive_hidden_items(self):
|
||||||
|
self.make_item("hidden", "png", "s", hidden=True)
|
||||||
|
self.assertEqual(self.guest.get("/api/random/").status_code, 404)
|
||||||
|
self.assertEqual(self.authed.get("/api/random/").status_code, 200)
|
||||||
|
|
||||||
|
def test_no_match_returns_404(self):
|
||||||
|
self.make_item("movie", "mp4", "s") # images only
|
||||||
|
self.assertEqual(self.authed.get("/api/random/").status_code, 404)
|
||||||
|
|
||||||
|
def test_short_top_level_alias(self):
|
||||||
|
self.make_item("alias", "gif", "s")
|
||||||
|
self.assertEqual(self.guest.get("/random/").status_code, 200)
|
||||||
|
self.assertEqual(self.guest.get("/random").status_code, 200)
|
||||||
@@ -17,6 +17,7 @@ from .views import (
|
|||||||
DownloadTaskViewSet,
|
DownloadTaskViewSet,
|
||||||
MatchTaskViewSet,
|
MatchTaskViewSet,
|
||||||
MediaItemViewSet,
|
MediaItemViewSet,
|
||||||
|
RandomItemView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -28,6 +29,7 @@ router.register("similarity", SimilarityCheckViewSet, basename="similarity")
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
path("random/", RandomItemView.as_view(), name="random_item"),
|
||||||
path("online/file/", ClientDownloadView.as_view(), name="client_download"),
|
path("online/file/", ClientDownloadView.as_view(), name="client_download"),
|
||||||
path(
|
path(
|
||||||
"duplicates/md5/",
|
"duplicates/md5/",
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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/`:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
Executable
+144
@@ -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)"
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"TCP": {
|
||||||
|
"8443": {
|
||||||
|
"HTTPS": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Web": {
|
||||||
|
"${TS_CERT_DOMAIN}:8443": {
|
||||||
|
"Handlers": {
|
||||||
|
"/": {
|
||||||
|
"Proxy": "http://127.0.0.1:80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"TCP": {
|
||||||
|
"443": {
|
||||||
|
"HTTPS": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Web": {
|
||||||
|
"${TS_CERT_DOMAIN}:443": {
|
||||||
|
"Handlers": {
|
||||||
|
"/": {
|
||||||
|
"Proxy": "http://127.0.0.1:80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"TCP": {
|
||||||
|
"443": {
|
||||||
|
"HTTPS": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Web": {
|
||||||
|
"${TS_CERT_DOMAIN}:443": {
|
||||||
|
"Handlers": {
|
||||||
|
"/": {
|
||||||
|
"Proxy": "http://127.0.0.1:80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+178
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user