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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user