Scoped API tokens for the random endpoint, with a management page

Backend: a GreetingToken model stores only a SHA-256 hash of a j621r_…
key (shown once at creation) plus label, prefix, created/last-used. A
dedicated GreetingTokenAuthentication understands the usual
'Authorization: Token …' header but is registered only on RandomItemView
(alongside the normal token auth), so a greeting token authenticates
/api/random/ and is rejected with 401 everywhere else — exactly the scope
shell greetings need. Endpoints: GET/POST /api/auth/greeting-tokens/ and
DELETE /api/auth/greeting-tokens/{id}/ (own tokens only; the list never
returns keys or hashes).

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

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

Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
This commit is contained in:
2026-09-18 13:37:29 -05:00
parent 2d9493d9fe
commit 770b1e5ee6
19 changed files with 678 additions and 9 deletions
+5
View File
@@ -77,6 +77,11 @@ curl -H "Authorization: Token <token>" \
the unambiguous path for scripts; 404 when nothing matches the filters. the unambiguous path for scripts; 404 when nothing matches the filters.
- A ready-made shell greeting that uses this endpoint lives in - A ready-made shell greeting that uses this endpoint lives in
[`extras/fish_greeting/`](extras/fish_greeting/README.md). [`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
+4
View File
@@ -26,6 +26,10 @@ they land.
(`?fastfetch=1` or a Fastfetch User-Agent) only returns png/jpg/gif as (`?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 JSON with a signed absolute link, for the fish_greeting scripts; the
`/random` SPA page rolls with rating pills and the `R` key `/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)
+42
View File
@@ -0,0 +1,42 @@
"""Authentication for scope-limited bearer tokens.
`GreetingTokenAuthentication` understands the same header a normal API token
uses (``Authorization: Token <key>``) but only resolves tokens issued for the
random-image endpoint. It is registered per-view (currently only
`RandomItemView`), so a greeting token is rejected everywhere else by the
regular DRF token authentication.
"""
from rest_framework import authentication, exceptions
from .models import GreetingToken
class GreetingTokenAuthentication(authentication.BaseAuthentication):
keyword = b"token"
def authenticate_header(self, request):
# DRF answers 401 (instead of 403) for AuthenticationFailed only when
# the first authenticator can name the scheme.
return "Token"
def authenticate(self, request):
header = authentication.get_authorization_header(request).split()
if not header or header[0].lower() != self.keyword:
return None
if len(header) != 2:
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
key = header[1].decode()
except UnicodeError:
raise exceptions.AuthenticationFailed("Invalid token header.")
# Not one of ours: let the regular token authentication handle it.
if not key.startswith(GreetingToken.PREFIX):
return None
token = GreetingToken.resolve(key)
if token is None:
raise exceptions.AuthenticationFailed("Invalid token.")
token.touch()
return (token.user, token)
@@ -0,0 +1,30 @@
# Generated by Django 6.1.1 on 2026-09-18 18:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_encrypt_e621_api_keys'),
]
operations = [
migrations.CreateModel(
name='GreetingToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key_hash', models.CharField(max_length=64, unique=True)),
('prefix', models.CharField(max_length=16)),
('label', models.CharField(blank=True, default='', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='greeting_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+64
View File
@@ -1,5 +1,9 @@
import secrets
from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils import timezone
class User(AbstractUser): class User(AbstractUser):
@@ -50,3 +54,63 @@ class User(AbstractUser):
return bool( return bool(
self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF
) )
def hash_bearer_token(value):
"""SHA-256 of a high-entropy bearer token (no salt needed)."""
import hashlib
return hashlib.sha256(value.encode()).hexdigest()
class GreetingToken(models.Model):
"""Long-lived token that only authenticates the random-image endpoint.
Meant for shell greetings and similar scripts, so it is safe to keep in a
config file: it cannot read the library, upload, or touch an account. Only
the SHA-256 hash is stored; the plaintext is returned once at creation.
"""
PREFIX = "j621r_"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="greeting_tokens",
)
key_hash = models.CharField(max_length=64, unique=True)
prefix = models.CharField(max_length=16)
label = models.CharField(max_length=100, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.prefix}… ({self.user})"
@classmethod
def issue(cls, user, label=""):
"""Create a token and return ``(token, plaintext_key)``."""
key = cls.PREFIX + secrets.token_hex(20)
token = cls.objects.create(
user=user,
key_hash=hash_bearer_token(key),
prefix=key[:12],
label=label.strip()[:100],
)
return token, key
@classmethod
def resolve(cls, key):
if not key.startswith(cls.PREFIX):
return None
return (
cls.objects.select_related("user")
.filter(key_hash=hash_bearer_token(key))
.first()
)
def touch(self):
GreetingToken.objects.filter(pk=self.pk).update(last_used_at=timezone.now())
+8 -1
View File
@@ -6,7 +6,7 @@ from rest_framework import serializers
from apps.library.services import VIDEO_EXTENSIONS from apps.library.services import VIDEO_EXTENSIONS
from apps.library.services import signed_media_url as signed_library_url from apps.library.services import signed_media_url as signed_library_url
from .models import User from .models import User, GreetingToken
def signed_media_url(request, item): def signed_media_url(request, item):
@@ -92,6 +92,13 @@ class UserUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False) role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
class GreetingTokenSerializer(serializers.ModelSerializer):
class Meta:
model = GreetingToken
fields = ["id", "prefix", "label", "created_at", "last_used_at"]
read_only_fields = fields
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, validators=[validate_password]) password = serializers.CharField(write_only=True, validators=[validate_password])
@@ -0,0 +1,166 @@
"""Scope-limited greeting tokens (`j621r_…`).
They exist so shell greetings and scripts can hold a credential that only
authenticates `/api/random/` — everything else must reject them — and they
are stored hashed, shown once.
"""
import hashlib
import json
import shutil
import tempfile
import time
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from rest_framework.authtoken.models import Token
from apps.accounts.models import GreetingToken, hash_bearer_token
from apps.library.models import MediaItem, MediaLocation
User = get_user_model()
def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
class GreetingTokenTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-tokens-")
cls._watched = Path(cls._tmp) / "library"
cls._watched.mkdir(parents=True, exist_ok=True)
cls._settings = override_settings(
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
)
cls._settings.enable()
@classmethod
def tearDownClass(cls):
cls._settings.disable()
shutil.rmtree(cls._tmp, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(
username="token-user", password="token-pass-123456"
)
self.other = User.objects.create_user(
username="token-other", password="token-pass-123456"
)
self.client = self.api_client(self.user)
self.other_client = self.api_client(self.other)
# One image so the random endpoint can answer.
path = self._watched / "token-test.png"
path.write_bytes(b"token-test")
self.item = MediaItem.objects.create(
md5=hashlib.md5(b"token-test").hexdigest(),
size=path.stat().st_size,
rating="s",
uploaded_by=self.user,
)
MediaLocation.objects.create(
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
)
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def token_client(self, key):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = f"Token {key}"
return client
def issue(self, client=None, label=""):
response = jpost(client or self.client, "/api/auth/greeting-tokens/", {"label": label})
self.assertEqual(response.status_code, 201)
return response.json()
def test_create_returns_the_key_once_and_stores_only_a_hash(self):
created = self.issue(self.client, "shell")
key = created["key"]
self.assertTrue(key.startswith("j621r_"))
self.assertEqual(created["label"], "shell")
token = GreetingToken.objects.get(pk=created["id"])
self.assertEqual(token.key_hash, hash_bearer_token(key))
self.assertNotIn(key, token.key_hash)
self.assertEqual(token.prefix, key[:12])
self.assertIsNone(token.last_used_at)
def test_list_hides_keys_and_hashes(self):
self.issue(self.client, "one")
self.issue(self.client, "two")
rows = self.client.get("/api/auth/greeting-tokens/").json()
self.assertEqual([row["label"] for row in rows], ["two", "one"])
for row in rows:
self.assertNotIn("key", row)
self.assertNotIn("key_hash", row)
self.assertTrue(row["prefix"].startswith("j621r_"))
def test_token_authenticates_random_and_nothing_else(self):
key = self.issue(self.other_client, "shell")["key"]
client = self.token_client(key)
response = client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
for method, path, body in (
("get", "/api/files/", None),
("get", "/api/storage/", None),
("get", "/api/auth/me/", None),
("get", "/api/tags/cloud/", None),
("post", "/api/delete/", {"j_ids": []}),
("get", "/api/auth/greeting-tokens/", None),
):
call = getattr(client, method)
if body is None:
self.assertEqual(call(path).status_code, 401, path)
else:
self.assertEqual(jpost(client, path, body).status_code, 401, path)
def test_normal_api_token_still_authenticates_random(self):
response = self.client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
def test_unknown_greeting_key_is_rejected(self):
client = self.token_client("j621r_" + "0" * 40)
self.assertEqual(client.get("/api/random/").status_code, 401)
def test_revoked_token_stops_working(self):
created = self.issue(self.client, "temporary")
client = self.token_client(created["key"])
self.assertEqual(client.get("/api/random/").status_code, 200)
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 204)
self.assertEqual(client.get("/api/random/").status_code, 401)
self.assertFalse(GreetingToken.objects.filter(pk=created["id"]).exists())
def test_cannot_revoke_someone_elses_token(self):
created = self.issue(self.other_client, "theirs")
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 404)
self.assertEqual(self.token_client(created["key"]).get("/api/random/").status_code, 200)
def test_last_used_is_recorded(self):
created = self.issue(self.client, "used")
self.token_client(created["key"]).get("/api/random/")
token = GreetingToken.objects.get(pk=created["id"])
self.assertIsNotNone(token.last_used_at)
def test_long_labels_are_rejected(self):
response = jpost(
self.client, "/api/auth/greeting-tokens/", {"label": "x" * 101}
)
self.assertEqual(response.status_code, 400)
+12
View File
@@ -3,6 +3,8 @@ from django.urls import path
from .views import ( from .views import (
AvatarView, AvatarView,
E621CredentialsView, E621CredentialsView,
GreetingTokenDetailView,
GreetingTokenListView,
LoginView, LoginView,
LogoutView, LogoutView,
MeView, MeView,
@@ -18,4 +20,14 @@ urlpatterns = [
path("avatar/", AvatarView.as_view(), name="avatar"), path("avatar/", AvatarView.as_view(), name="avatar"),
path("preferences/", PreferencesView.as_view(), name="preferences"), path("preferences/", PreferencesView.as_view(), name="preferences"),
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"), path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
path(
"greeting-tokens/",
GreetingTokenListView.as_view(),
name="greeting_tokens",
),
path(
"greeting-tokens/<int:pk>/",
GreetingTokenDetailView.as_view(),
name="greeting_token",
),
] ]
+49 -1
View File
@@ -1,5 +1,6 @@
import logging import logging
from django.http import Http404
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
@@ -13,9 +14,10 @@ from apps.core.permissions import IsAppStaff
from apps.library.models import MediaItem from apps.library.models import MediaItem
from .crypto import encrypt_secret from .crypto import encrypt_secret
from .models import User from .models import GreetingToken, User
from .serializers import ( from .serializers import (
E621CredentialsSerializer, E621CredentialsSerializer,
GreetingTokenSerializer,
PreferencesSerializer, PreferencesSerializer,
RegisterSerializer, RegisterSerializer,
UserListSerializer, UserListSerializer,
@@ -157,6 +159,52 @@ class PreferencesView(APIView):
return Response(preferences) return Response(preferences)
class GreetingTokenListView(APIView):
"""List and create the caller's random-endpoint tokens.
The plaintext key is returned once on creation; only its hash is stored,
and it only authenticates `/api/random/` (see GreetingToken).
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tokens = GreetingToken.objects.filter(user=request.user)
return Response(GreetingTokenSerializer(tokens, many=True).data)
def post(self, request):
label = str(request.data.get("label") or "").strip()
if len(label) > 100:
return Response(
{"detail": "Label is too long (100 characters max)."},
status=status.HTTP_400_BAD_REQUEST,
)
token, key = GreetingToken.issue(request.user, label)
logger.info(
"Greeting token %s created by %s", token.prefix, request.user.username
)
return Response(
{**GreetingTokenSerializer(token).data, "key": key},
status=status.HTTP_201_CREATED,
)
class GreetingTokenDetailView(APIView):
"""Revoke one of the caller's tokens."""
permission_classes = [IsAuthenticated]
def delete(self, request, pk):
token = GreetingToken.objects.filter(pk=pk, user=request.user).first()
if token is None:
raise Http404
logger.info(
"Greeting token %s revoked by %s", token.prefix, request.user.username
)
token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserViewSet( class UserViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
+7
View File
@@ -12,12 +12,15 @@ from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.accounts.auth import GreetingTokenAuthentication
from . import e621, matching, services from . import e621, matching, services
from .downloads import reap_stale_downloads, start_download_task from .downloads import reap_stale_downloads, start_download_task
from .matching import reap_stale_match_tasks, start_match_task from .matching import reap_stale_match_tasks, start_match_task
@@ -534,8 +537,12 @@ class RandomItemView(APIView):
display. Responses always carry a signed absolute URL (minted for the display. Responses always carry a signed absolute URL (minted for the
requesting user) so image viewers can load it without auth headers; requesting user) so image viewers can load it without auth headers;
guests get unsigned URLs for guest-visible items only. 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] permission_classes = [AllowAny]
FASTFETCH_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif"} FASTFETCH_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif"}
+30 -5
View File
@@ -27,7 +27,22 @@ cd extras/fish_greeting
fish install.fish fish install.fish
``` ```
Then edit `~/.config/j621Greeting/config.fish` (at least `J621_BASE`) and test: 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.
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
fish_greeting fish_greeting
@@ -37,6 +52,14 @@ Requirements: `curl` (or `wget`), `fastfetch`, `file`. Optional: `gifsicle`
(GIF downscaling), `jq` or `python3` (JSON parsing — without either it falls (GIF downscaling), `jq` or `python3` (JSON parsing — without either it falls
back to grep/sed). 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 ## Configuration
Any of these work; the config file is read by the function on every run: Any of these work; the config file is read by the function on every run:
@@ -49,10 +72,12 @@ set -g J621_TOKEN <api token> # signed URLs + hidden it
set -g J621_FASTFETCH_CONFIG jake # fastfetch config name set -g J621_FASTFETCH_CONFIG jake # fastfetch config name
``` ```
`J621_TOKEN` is the same token the SPA uses (`Authorization: Token …`). With `J621_TOKEN` is optional. The recommended value is a **scoped greeting
it the API signs the image URL for your account, so you also get items that token**: open the app, go to *Account → Shell tokens* (or `/tokens`), create
are hidden from guests. Without it the greeting runs as a guest and sees the one and copy the `j621r_…` key — it only works with `/api/random/`, so it is
public library only. 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 ## Rating filters
+4 -2
View File
@@ -9,8 +9,10 @@ set -g J621_BASE https://j621.rainbow-herring.ts.net
# set -g J621_WEB https://j621-frontend.rainbow-herring.ts.net # set -g J621_WEB https://j621-frontend.rainbow-herring.ts.net
# API token. Optional: gives you signed image URLs and access to items that # API token. Optional: gives you signed image URLs and access to items that
# are hidden from guests. Create one with `manage.py drf_create_token <user>` # are hidden from guests. Use a scoped greeting token (Account -> Shell
# or copy it from the app (it is the same token the SPA stores). # 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 # set -g J621_TOKEN paste-your-token-here
# fastfetch config name used for the greeting logo. # fastfetch config name used for the greeting logo.
+11
View File
@@ -27,6 +27,7 @@ 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";
@@ -183,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
@@ -8,6 +8,7 @@ import {
Globe, Globe,
History, History,
Images, Images,
KeyRound,
Layers, Layers,
LogIn, LogIn,
LogOut, LogOut,
@@ -220,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>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { GreetingToken, GreetingTokenCreated } from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
export default function TokensPage() {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [fresh, setFresh] = useState<GreetingTokenCreated | null>(null);
const query = useQuery({
queryKey: ["greeting-tokens"],
queryFn: () => api<GreetingToken[]>("/api/auth/greeting-tokens/"),
});
const createMutation = useMutation({
mutationFn: () =>
api<GreetingTokenCreated>("/api/auth/greeting-tokens/", {
method: "POST",
json: { label: label.trim() },
}),
onSuccess: (created) => {
setFresh(created);
setLabel("");
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const revokeMutation = useMutation({
mutationFn: (id: number) =>
api(`/api/auth/greeting-tokens/${id}/`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
toast.ok("Token revoked.");
},
onError: (error) => toast.error(errorMessage(error)),
});
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
toast.ok(`${what} copied.`);
} catch {
toast.error("Could not copy — select the text and copy it manually.");
}
}
async function revoke(token: GreetingToken) {
const confirmed = await confirmAction({
title: `Revoke ${token.prefix}…?`,
description:
"Whatever uses this token (your shell greeting, a script) stops working immediately. You can create a new one any time.",
confirmLabel: "Revoke",
danger: true,
});
if (confirmed) revokeMutation.mutate(token.id);
}
const tokens = query.data ?? [];
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">API tokens</h1>
<p className="mt-1 text-sm leading-relaxed text-ctp-overlay0">
Tokens that only work with the random-image endpoint
(<span className="font-mono">/api/random/</span>) — made for shell
greetings and little scripts. They cannot read the library, upload,
delete or change your account, and only a hash is stored on the
server.
</p>
</header>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Create a token
</h2>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className={cn(inputClass, "max-w-xs flex-1")}
placeholder="Label, e.g. laptop greeting"
value={label}
onChange={(event) => setLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !createMutation.isPending) {
createMutation.mutate();
}
}}
/>
<Button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<KeyRound className="h-4 w-4" />
{createMutation.isPending ? "Creating…" : "Create token"}
</Button>
</div>
{fresh ? (
<div className="mt-4 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-3">
<p className="text-xs font-medium text-ctp-green">
Copy this key now — it is not shown again.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded bg-ctp-crust px-2 py-1.5 font-mono text-xs text-ctp-text">
{fresh.key}
</code>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() => void copy(fresh.key, "Token")}
>
<Copy className="h-3.5 w-3.5" /> Copy
</Button>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() =>
void copy(`set -g J621_TOKEN ${fresh.key}`, "fish line")
}
>
<Copy className="h-3.5 w-3.5" /> Copy for fish
</Button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-ctp-overlay0">
Shell greetings: put that line in{" "}
<span className="font-mono">
~/.config/j621Greeting/config.fish
</span>{" "}
(see <span className="font-mono">extras/fish_greeting</span> in
the repository).
</p>
</div>
) : null}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Your tokens
</h2>
{query.isPending ? (
<div className="flex justify-center py-12">
<Spinner className="h-5 w-5" />
</div>
) : query.isError ? (
<EmptyState
title="Could not load tokens"
description={errorMessage(query.error)}
/>
) : tokens.length === 0 ? (
<EmptyState
title="No tokens yet"
description="Create one above to use the random endpoint from a script."
/>
) : (
<ul className="flex flex-col gap-2">
{tokens.map((token) => (
<li
key={token.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-ctp-surface0 bg-ctp-base px-3 py-2"
>
<span className="font-mono text-xs text-ctp-subtext1">
{token.prefix}…
</span>
<span className="text-xs text-ctp-subtext0">
{token.label || "no label"}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
created {formatDate(token.created_at)}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
{token.last_used_at
? `last used ${formatDate(token.last_used_at)}`
: "never used"}
</span>
<Button
variant="ghost"
className="ml-auto px-2 py-1 text-xs text-ctp-red hover:bg-ctp-red/15"
disabled={revokeMutation.isPending}
onClick={() => void revoke(token)}
title="Revoke this token"
>
<Trash2 className="h-3.5 w-3.5" /> Revoke
</Button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+13
View File
@@ -1,3 +1,16 @@
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 { export interface RandomItem {
j_id: string; j_id: string;
md5: string; md5: string;