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.
139 lines
4.4 KiB
Python
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)
|