diff --git a/backend/apps/accounts/migrations/0002_user_e621_api_key_user_e621_base_url_and_more.py b/backend/apps/accounts/migrations/0002_user_e621_api_key_user_e621_base_url_and_more.py new file mode 100644 index 0000000..1eadaef --- /dev/null +++ b/backend/apps/accounts/migrations/0002_user_e621_api_key_user_e621_base_url_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.1.1 on 2026-09-17 13:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='e621_api_key', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='user', + name='e621_base_url', + field=models.CharField(default='https://e621.net', max_length=200), + ), + migrations.AddField( + model_name='user', + name='e621_username', + field=models.CharField(blank=True, default='', max_length=100), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 5e8894d..7878011 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -1,5 +1,14 @@ from django.contrib.auth.models import AbstractUser +from django.db import models class User(AbstractUser): - """Project user. Extended later with avatars and e621 credentials.""" + """Project user with optional e621 API credentials.""" + + e621_username = models.CharField(max_length=100, blank=True, default="") + e621_api_key = models.CharField(max_length=100, blank=True, default="") + e621_base_url = models.CharField(max_length=200, default="https://e621.net") + + @property + def e621_configured(self): + return bool(self.e621_username and self.e621_api_key) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 2ffe1c2..5d4c6c1 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -5,9 +5,19 @@ from .models import User class UserSerializer(serializers.ModelSerializer): + e621_configured = serializers.BooleanField(read_only=True) + class Meta: model = User - fields = ["id", "username", "email", "is_staff", "date_joined"] + fields = [ + "id", + "username", + "email", + "is_staff", + "date_joined", + "e621_username", + "e621_configured", + ] read_only_fields = fields @@ -20,3 +30,13 @@ class RegisterSerializer(serializers.ModelSerializer): 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 + ) diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 2d538f6..0f4597a 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -1,11 +1,12 @@ from django.urls import path from rest_framework.authtoken.views import obtain_auth_token -from .views import LogoutView, MeView, RegisterView +from .views import E621CredentialsView, LogoutView, MeView, RegisterView urlpatterns = [ path("register/", RegisterView.as_view(), name="register"), path("token/", obtain_auth_token, name="login"), path("logout/", LogoutView.as_view(), name="logout"), path("me/", MeView.as_view(), name="me"), + path("e621/", E621CredentialsView.as_view(), name="e621_credentials"), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 0c284db..ad92d31 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -4,7 +4,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from .serializers import RegisterSerializer, UserSerializer +from .serializers import ( + E621CredentialsSerializer, + RegisterSerializer, + UserSerializer, +) class RegisterView(APIView): @@ -35,3 +39,35 @@ class LogoutView(APIView): if request.auth: request.auth.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class E621CredentialsView(APIView): + """Store the e621 credentials used by the user's browser to call e621 directly.""" + + permission_classes = [IsAuthenticated] + + def _payload(self, user): + return { + "username": user.e621_username, + "api_key": user.e621_api_key, + "base_url": user.e621_base_url, + "configured": user.e621_configured, + } + + def get(self, request): + return Response(self._payload(request.user)) + + def put(self, request): + serializer = E621CredentialsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + user = request.user + user.e621_username = data["username"].strip() + user.e621_api_key = data["api_key"].strip() + user.e621_base_url = ( + (data.get("base_url") or "https://e621.net").strip().rstrip("/") + ) + user.save( + update_fields=["e621_username", "e621_api_key", "e621_base_url"] + ) + return Response(self._payload(user)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fcffa4..fbf738c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { AppShell } from "@/components/AppShell"; import { FullPageSpinner } from "@/components/ui"; +import AccountPage from "@/features/account/AccountPage"; import LoginPage from "@/features/auth/LoginPage"; import RegisterPage from "@/features/auth/RegisterPage"; import FileDetailPage from "@/features/library/FileDetailPage"; @@ -44,6 +45,14 @@ export default function App() { } /> + + + + } + /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d523c60..14c600f 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -100,14 +100,18 @@ export function AppShell() { {user ? ( <> -
+ {user.username.slice(0, 1).toUpperCase()} {user.username} -
+ + + + + +
+ + {baseChoice === "custom" ? ( + setCustomBase(event.target.value)} + /> + ) : null} +
+
+ + {saveError ? ( +

+ {saveError} +

+ ) : null} + {saveMessage ? ( +

{saveMessage}

+ ) : null} + {testResult ? ( +

+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +

+ ) : null} + +
+ + +
+ +

+ Stored on your server and fetched by this app when needed. e621 requests + are made directly from your browser, throttled to one per second. +

+ + + ); +} + +export default function AccountPage() { + const user = useAuth((state) => state.user); + const credentials = useE621((state) => state.credentials); + const loading = useE621((state) => state.loading); + const load = useE621((state) => state.load); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+

Account

+ {user ? ( +

+ Signed in as {user.username}. +

+ ) : null} +
+ {loading && !credentials ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts new file mode 100644 index 0000000..59df447 --- /dev/null +++ b/frontend/src/lib/e621.ts @@ -0,0 +1,144 @@ +export interface E621Credentials { + username: string; + api_key: string; + base_url: string; + configured: boolean; +} + +export interface E621User { + id: number; + name: string; + favorite_count?: number; + blacklisted_tags?: string; +} + +export class E621Error extends Error { + status: number; + data: unknown; + + constructor(status: number, data: unknown) { + super(`e621 request failed (${status})`); + this.name = "E621Error"; + this.status = status; + this.data = data; + } +} + +const CLIENT_VERSION = "J621/0.1"; + +// e621 allows 2 requests/second hard, 1/second sustained. Serialize every +// request through a queue with a 1s minimum gap. +let lastRequestAt = 0; +let queue: Promise = Promise.resolve(); + +function schedule(task: () => Promise): Promise { + const run = queue.then(task, task); + queue = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function throttle(): Promise { + const wait = Math.max(0, lastRequestAt + 1000 - Date.now()); + if (wait > 0) { + await new Promise((resolve) => setTimeout(resolve, wait)); + } + lastRequestAt = Date.now(); +} + +interface E621RequestOptions { + params?: Record; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: BodyInit; + signal?: AbortSignal; +} + +export function e621Request( + credentials: E621Credentials, + path: string, + options: E621RequestOptions = {}, +): Promise { + return schedule(async () => { + await throttle(); + + const base = credentials.base_url.replace(/\/+$/, ""); + const url = new URL(`${base}/${path.replace(/^\/+/, "")}`); + // Browsers cannot set User-Agent; e621 documents `_client` for this case. + url.searchParams.set( + "_client", + `${CLIENT_VERSION} (by ${credentials.username || "anonymous"} on e621)`, + ); + for (const [key, value] of Object.entries(options.params ?? {})) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const headers = new Headers(); + if (credentials.username && credentials.api_key) { + headers.set( + "Authorization", + `Basic ${btoa(`${credentials.username}:${credentials.api_key}`)}`, + ); + } + + const response = await fetch(url, { + method: options.method ?? "GET", + headers, + body: options.body, + signal: options.signal, + }); + + if (!response.ok) { + let data: unknown = null; + try { + data = await response.json(); + } catch { + data = null; + } + throw new E621Error(response.status, data); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + }); +} + +export function e621ErrorMessage(error: unknown): string { + if (error instanceof E621Error) { + const data = error.data; + if (data && typeof data === "object") { + const record = data as Record; + for (const key of ["reason", "error", "message", "detail"]) { + if (typeof record[key] === "string") return record[key] as string; + } + } + if (error.status === 401) return "Invalid e621 username or API key."; + if (error.status === 403) { + return "e621 refused the request. Check that API access is enabled on your e621 account."; + } + if (error.status === 429 || error.status === 503) { + return "Hit the e621 rate limit — wait a moment and try again."; + } + return `e621 request failed (${error.status}).`; + } + if (error instanceof TypeError) { + return "Could not reach e621. Check the base URL and your connection."; + } + return error instanceof Error + ? error.message + : "Something went wrong talking to e621."; +} + +export async function verifyE621Credentials( + credentials: E621Credentials, +): Promise { + const data = await e621Request<{ user?: E621User } & Partial>( + credentials, + "/users/me.json", + ); + return data.user ?? (data as E621User); +} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 54983d7..cd79ad7 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { api, getToken, setToken } from "@/lib/api"; import type { User } from "@/lib/types"; +import { useE621 } from "@/store/e621"; interface AuthState { user: User | null; @@ -42,6 +43,7 @@ export const useAuth = create((set) => ({ // The token may already be invalid; clearing it locally is enough. } setToken(null); + useE621.getState().clear(); set({ user: null, ready: true }); }, diff --git a/frontend/src/store/e621.ts b/frontend/src/store/e621.ts new file mode 100644 index 0000000..8dcae98 --- /dev/null +++ b/frontend/src/store/e621.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +import { api } from "@/lib/api"; +import type { E621Credentials } from "@/lib/e621"; + +interface E621State { + credentials: E621Credentials | null; + loading: boolean; + load: () => Promise; + save: (values: { + username: string; + api_key: string; + base_url: string; + }) => Promise; + clear: () => void; +} + +export const useE621 = create((set) => ({ + credentials: null, + loading: false, + + load: async () => { + set({ loading: true }); + try { + const credentials = await api("/api/auth/e621/"); + set({ credentials, loading: false }); + } catch { + set({ credentials: null, loading: false }); + } + }, + + save: async (values) => { + const credentials = await api("/api/auth/e621/", { + method: "PUT", + json: values, + }); + set({ credentials }); + return credentials; + }, + + clear: () => set({ credentials: null }), +}));