From 770b1e5ee68d53977ec11054b16b7dd3460fae1e Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 13:37:29 -0500 Subject: [PATCH] Scoped API tokens for the random endpoint, with a management page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 5 + ROADMAP.md | 4 + backend/apps/accounts/auth.py | 42 ++++ .../accounts/migrations/0007_greetingtoken.py | 30 +++ backend/apps/accounts/models.py | 64 ++++++ backend/apps/accounts/serializers.py | 9 +- backend/apps/accounts/tests/__init__.py | 0 .../accounts/tests/test_greeting_tokens.py | 166 +++++++++++++++ backend/apps/accounts/urls.py | 12 ++ backend/apps/accounts/views.py | 50 ++++- backend/apps/library/views.py | 7 + extras/fish_greeting/README.md | 35 ++- extras/fish_greeting/config.example.fish | 6 +- frontend/src/App.tsx | 11 + frontend/src/components/CommandPalette.tsx | 7 + frontend/src/features/account/AccountPage.tsx | 2 + frontend/src/features/account/TokensCard.tsx | 23 ++ frontend/src/features/tokens/TokensPage.tsx | 201 ++++++++++++++++++ frontend/src/lib/types.ts | 13 ++ 19 files changed, 678 insertions(+), 9 deletions(-) create mode 100644 backend/apps/accounts/auth.py create mode 100644 backend/apps/accounts/migrations/0007_greetingtoken.py create mode 100644 backend/apps/accounts/tests/__init__.py create mode 100644 backend/apps/accounts/tests/test_greeting_tokens.py create mode 100644 frontend/src/features/account/TokensCard.tsx create mode 100644 frontend/src/features/tokens/TokensPage.tsx diff --git a/README.md b/README.md index 8e9a0c2..a21381c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ curl -H "Authorization: Token " \ 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 diff --git a/ROADMAP.md b/ROADMAP.md index ac6d1d9..3b63c88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,10 @@ they land. (`?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] Drop a file: exact MD5 match, perceptual matches against the library, and e621 IQDB candidates (auto-run for images) diff --git a/backend/apps/accounts/auth.py b/backend/apps/accounts/auth.py new file mode 100644 index 0000000..04f1507 --- /dev/null +++ b/backend/apps/accounts/auth.py @@ -0,0 +1,42 @@ +"""Authentication for scope-limited bearer tokens. + +`GreetingTokenAuthentication` understands the same header a normal API token +uses (``Authorization: Token ``) 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) diff --git a/backend/apps/accounts/migrations/0007_greetingtoken.py b/backend/apps/accounts/migrations/0007_greetingtoken.py new file mode 100644 index 0000000..f698075 --- /dev/null +++ b/backend/apps/accounts/migrations/0007_greetingtoken.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index a55b024..0b40b7b 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -1,5 +1,9 @@ +import secrets + +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.utils import timezone class User(AbstractUser): @@ -50,3 +54,63 @@ class User(AbstractUser): return bool( 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()) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 0df54f8..6ec6208 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from apps.library.services import VIDEO_EXTENSIONS 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): @@ -92,6 +92,13 @@ class UserUpdateSerializer(serializers.Serializer): 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): password = serializers.CharField(write_only=True, validators=[validate_password]) diff --git a/backend/apps/accounts/tests/__init__.py b/backend/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/tests/test_greeting_tokens.py b/backend/apps/accounts/tests/test_greeting_tokens.py new file mode 100644 index 0000000..956a002 --- /dev/null +++ b/backend/apps/accounts/tests/test_greeting_tokens.py @@ -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) diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 154202e..a44b483 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -3,6 +3,8 @@ from django.urls import path from .views import ( AvatarView, E621CredentialsView, + GreetingTokenDetailView, + GreetingTokenListView, LoginView, LogoutView, MeView, @@ -18,4 +20,14 @@ urlpatterns = [ path("avatar/", AvatarView.as_view(), name="avatar"), path("preferences/", PreferencesView.as_view(), name="preferences"), path("e621/", E621CredentialsView.as_view(), name="e621_credentials"), + path( + "greeting-tokens/", + GreetingTokenListView.as_view(), + name="greeting_tokens", + ), + path( + "greeting-tokens//", + GreetingTokenDetailView.as_view(), + name="greeting_token", + ), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 01f1c1c..0f60c9d 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,5 +1,6 @@ import logging +from django.http import Http404 from rest_framework import mixins, status, viewsets from rest_framework.authtoken.models import Token from rest_framework.authtoken.views import ObtainAuthToken @@ -13,9 +14,10 @@ from apps.core.permissions import IsAppStaff from apps.library.models import MediaItem from .crypto import encrypt_secret -from .models import User +from .models import GreetingToken, User from .serializers import ( E621CredentialsSerializer, + GreetingTokenSerializer, PreferencesSerializer, RegisterSerializer, UserListSerializer, @@ -157,6 +159,52 @@ class PreferencesView(APIView): 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( mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index 7a25277..46819d3 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -12,12 +12,15 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.text import get_valid_filename from rest_framework import mixins, status, viewsets +from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +from apps.accounts.auth import GreetingTokenAuthentication + from . import e621, matching, services from .downloads import reap_stale_downloads, start_download_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 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"} diff --git a/extras/fish_greeting/README.md b/extras/fish_greeting/README.md index b123909..b7aac79 100644 --- a/extras/fish_greeting/README.md +++ b/extras/fish_greeting/README.md @@ -27,7 +27,22 @@ cd extras/fish_greeting 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 +fish install.fish --no-prompt # defaults, never asks +fish install.fish --force # rewrite an existing config +``` + +Then test: ```fish 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 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: @@ -49,10 +72,12 @@ set -g J621_TOKEN # signed URLs + hidden it set -g J621_FASTFETCH_CONFIG jake # fastfetch config name ``` -`J621_TOKEN` is the same token the SPA uses (`Authorization: Token …`). With -it the API signs the image URL for your account, so you also get items that -are hidden from guests. Without it the greeting runs as a guest and sees the -public library only. +`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 diff --git a/extras/fish_greeting/config.example.fish b/extras/fish_greeting/config.example.fish index a1a2004..e5ff909 100644 --- a/extras/fish_greeting/config.example.fish +++ b/extras/fish_greeting/config.example.fish @@ -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 # 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 ` -# or copy it from the app (it is the same token the SPA stores). +# 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d542e2d..c13b60f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import RandomPage from "@/features/random/RandomPage"; import SimilarPage from "@/features/similar/SimilarPage"; import { SetupPage } from "@/features/setup/SetupPage"; import StatsPage from "@/features/stats/StatsPage"; +import TokensPage from "@/features/tokens/TokensPage"; import UploadPage from "@/features/upload/UploadPage"; import UsersPage from "@/features/users/UsersPage"; import { isAgeVerified, markAgeVerified } from "@/lib/age"; @@ -183,6 +184,16 @@ export default function App() { } /> } /> + + + + + + } + /> } /> void }) { icon: Settings, 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") { list.push({ id: "users", diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx index 4d5de3c..0be3e00 100644 --- a/frontend/src/features/account/AccountPage.tsx +++ b/frontend/src/features/account/AccountPage.tsx @@ -17,6 +17,7 @@ import { toast } from "@/store/toasts"; import { AvatarCard } from "./AvatarCard"; import { PreferencesCard } from "./PreferencesCard"; +import { TokensCard } from "./TokensCard"; const BASE_URL_OPTIONS = [ { value: "https://e621.net", label: "e621.net — main site" }, @@ -247,6 +248,7 @@ export default function AccountPage() { + {loading && !credentials ? (
diff --git a/frontend/src/features/account/TokensCard.tsx b/frontend/src/features/account/TokensCard.tsx new file mode 100644 index 0000000..2c090de --- /dev/null +++ b/frontend/src/features/account/TokensCard.tsx @@ -0,0 +1,23 @@ +import { Link } from "react-router-dom"; + +import { linkButtonClass } from "@/components/ui"; + +export function TokensCard() { + return ( +
+

+ Shell tokens +

+

+ 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. +

+
+ + Manage API tokens + +
+
+ ); +} diff --git a/frontend/src/features/tokens/TokensPage.tsx b/frontend/src/features/tokens/TokensPage.tsx new file mode 100644 index 0000000..502c3b6 --- /dev/null +++ b/frontend/src/features/tokens/TokensPage.tsx @@ -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(null); + + const query = useQuery({ + queryKey: ["greeting-tokens"], + queryFn: () => api("/api/auth/greeting-tokens/"), + }); + + const createMutation = useMutation({ + mutationFn: () => + api("/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 ( +
+
+

API tokens

+

+ Tokens that only work with the random-image endpoint + (/api/random/) — 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. +

+
+ +
+

+ Create a token +

+
+ setLabel(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !createMutation.isPending) { + createMutation.mutate(); + } + }} + /> + +
+ + {fresh ? ( +
+

+ Copy this key now — it is not shown again. +

+
+ + {fresh.key} + + + +
+

+ Shell greetings: put that line in{" "} + + ~/.config/j621Greeting/config.fish + {" "} + (see extras/fish_greeting in + the repository). +

+
+ ) : null} +
+ +
+

+ Your tokens +

+ + {query.isPending ? ( +
+ +
+ ) : query.isError ? ( + + ) : tokens.length === 0 ? ( + + ) : ( +
    + {tokens.map((token) => ( +
  • + + {token.prefix}… + + + {token.label || "no label"} + + + created {formatDate(token.created_at)} + + + {token.last_used_at + ? `last used ${formatDate(token.last_used_at)}` + : "never used"} + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 82b46cb..ccdfd83 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { j_id: string; md5: string;