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

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

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

Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
2026-09-18 13:37:29 -05:00

139 lines
4.4 KiB
Python

from pathlib import Path
from django.contrib.auth.password_validation import validate_password
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, GreetingToken
def signed_media_url(request, item):
"""A signed media URL an <img> tag can load for an authenticated user."""
if item is None:
return None
user = getattr(request, "user", None)
if user is None or not getattr(user, "is_authenticated", False):
return None
location = item.locations.first()
is_video = (
location is not None
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
)
action = "thumbnail" if is_video else "raw"
return signed_library_url(item, user, action, request=request)
class UserSerializer(serializers.ModelSerializer):
e621_configured = serializers.BooleanField(read_only=True)
can_upload = serializers.BooleanField(read_only=True)
avatar_j_id = serializers.SerializerMethodField()
avatar_url = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
"id",
"username",
"email",
"is_staff",
"is_superuser",
"date_joined",
"role",
"can_upload",
"avatar_j_id",
"avatar_url",
"e621_username",
"e621_configured",
"preferences",
]
read_only_fields = fields
def get_avatar_j_id(self, obj):
return f"J-{obj.avatar_id}" if obj.avatar_id else None
def get_avatar_url(self, obj):
return signed_media_url(self.context.get("request"), obj.avatar)
class UserListSerializer(serializers.ModelSerializer):
avatar_j_id = serializers.SerializerMethodField()
avatar_url = serializers.SerializerMethodField()
uploads_count = serializers.IntegerField(read_only=True)
class Meta:
model = User
fields = [
"id",
"username",
"email",
"role",
"is_staff",
"is_superuser",
"date_joined",
"avatar_j_id",
"avatar_url",
"uploads_count",
]
read_only_fields = fields
def get_avatar_j_id(self, obj):
return f"J-{obj.avatar_id}" if obj.avatar_id else None
def get_avatar_url(self, obj):
return signed_media_url(self.context.get("request"), obj.avatar)
class UserUpdateSerializer(serializers.Serializer):
avatar_j_id = serializers.CharField(
required=False, allow_blank=True, allow_null=True
)
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])
class Meta:
model = User
fields = ["username", "email", "password"]
def create(self, validated_data):
return User.objects.create_user(**validated_data)
class E621CredentialsSerializer(serializers.Serializer):
username = serializers.CharField(max_length=100, allow_blank=True)
api_key = serializers.CharField(
max_length=100, allow_blank=True, trim_whitespace=True
)
base_url = serializers.URLField(
max_length=200, required=False, allow_blank=True
)
class PreferencesSerializer(serializers.Serializer):
"""Per-user browse preferences; unknown keys are ignored."""
LANDING_PAGES = ["library", "online", "pools", "followed"]
landing_page = serializers.ChoiceField(choices=LANDING_PAGES, required=False)
ratings = serializers.ListField(
child=serializers.ChoiceField(choices=["s", "q", "e"]),
required=False,
max_length=3,
)
ordering = serializers.CharField(max_length=30, required=False)
per_page = serializers.IntegerField(min_value=12, max_value=200, required=False)
zoom = serializers.IntegerField(min_value=120, max_value=400, required=False)
# Open Online with the order:hot metatag when no search is given.
online_hot_default = serializers.BooleanField(required=False)