e621 credentials: user fields, API endpoint, Account screen
Backend: - User model gains e621_username / e621_api_key / e621_base_url - GET/PUT /api/auth/e621/ for the owner's credentials; /me exposes only the username and a configured flag, never the key Frontend: - e621 client core: Basic auth, _client param (browsers cannot set a User-Agent), serialized queue throttled to 1 request/second, readable error mapping - Account screen (/account): username, API key with reveal toggle, base URL (e621 / e926 / custom), Save + Test connection - Credentials are fetched from the backend and held in memory only, cleared on logout
This commit is contained in:
+28
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
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)
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ from .models import User
|
|||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
e621_configured = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
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
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
@@ -20,3 +30,13 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
return User.objects.create_user(**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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
from rest_framework.authtoken.views import obtain_auth_token
|
||||||
|
|
||||||
from .views import LogoutView, MeView, RegisterView
|
from .views import E621CredentialsView, LogoutView, MeView, RegisterView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
path("token/", obtain_auth_token, name="login"),
|
path("token/", obtain_auth_token, name="login"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
path("me/", MeView.as_view(), name="me"),
|
path("me/", MeView.as_view(), name="me"),
|
||||||
|
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .serializers import RegisterSerializer, UserSerializer
|
from .serializers import (
|
||||||
|
E621CredentialsSerializer,
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RegisterView(APIView):
|
class RegisterView(APIView):
|
||||||
@@ -35,3 +39,35 @@ class LogoutView(APIView):
|
|||||||
if request.auth:
|
if request.auth:
|
||||||
request.auth.delete()
|
request.auth.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
|||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { FullPageSpinner } from "@/components/ui";
|
import { FullPageSpinner } from "@/components/ui";
|
||||||
|
import AccountPage from "@/features/account/AccountPage";
|
||||||
import LoginPage from "@/features/auth/LoginPage";
|
import LoginPage from "@/features/auth/LoginPage";
|
||||||
import RegisterPage from "@/features/auth/RegisterPage";
|
import RegisterPage from "@/features/auth/RegisterPage";
|
||||||
import FileDetailPage from "@/features/library/FileDetailPage";
|
import FileDetailPage from "@/features/library/FileDetailPage";
|
||||||
@@ -44,6 +45,14 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AccountPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|||||||
@@ -100,14 +100,18 @@ export function AppShell() {
|
|||||||
<StatusPill status={status} />
|
<StatusPill status={status} />
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<Link
|
||||||
|
to="/account"
|
||||||
|
title="Account settings"
|
||||||
|
className="flex items-center gap-2 transition hover:opacity-80"
|
||||||
|
>
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
|
||||||
{user.username.slice(0, 1).toUpperCase()}
|
{user.username.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden text-sm text-ctp-subtext1 sm:block">
|
<span className="hidden text-sm text-ctp-subtext1 sm:block">
|
||||||
{user.username}
|
{user.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void logout()}
|
onClick={() => void logout()}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
Upload,
|
Upload,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -76,6 +77,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (user) {
|
if (user) {
|
||||||
|
list.push({
|
||||||
|
id: "account",
|
||||||
|
label: "Account settings",
|
||||||
|
icon: Settings,
|
||||||
|
run: () => navigate("/account"),
|
||||||
|
});
|
||||||
list.push({
|
list.push({
|
||||||
id: "logout",
|
id: "logout",
|
||||||
label: `Log out (${user.username})`,
|
label: `Log out (${user.username})`,
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { CheckCircle2, Eye, EyeOff, XCircle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button, Field, Spinner, inputClass } from "@/components/ui";
|
||||||
|
import { errorMessage } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import {
|
||||||
|
e621ErrorMessage,
|
||||||
|
verifyE621Credentials,
|
||||||
|
type E621Credentials,
|
||||||
|
} from "@/lib/e621";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useE621 } from "@/store/e621";
|
||||||
|
|
||||||
|
const BASE_URL_OPTIONS = [
|
||||||
|
{ value: "https://e621.net", label: "e621.net — main site" },
|
||||||
|
{ value: "https://e926.net", label: "e926.net — safe mirror" },
|
||||||
|
{ value: "custom", label: "Custom…" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
|
||||||
|
const save = useE621((state) => state.save);
|
||||||
|
|
||||||
|
const [username, setUsername] = useState(credentials?.username ?? "");
|
||||||
|
const [apiKey, setApiKey] = useState(credentials?.api_key ?? "");
|
||||||
|
const savedBase = credentials?.base_url ?? "https://e621.net";
|
||||||
|
const savedBaseIsKnown = BASE_URL_OPTIONS.some(
|
||||||
|
(option) => option.value === savedBase,
|
||||||
|
);
|
||||||
|
const [baseChoice, setBaseChoice] = useState(
|
||||||
|
savedBaseIsKnown ? savedBase : "custom",
|
||||||
|
);
|
||||||
|
const [customBase, setCustomBase] = useState(savedBaseIsKnown ? "" : savedBase);
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const baseUrl = (baseChoice === "custom" ? customBase.trim() : baseChoice).replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const values = {
|
||||||
|
username: username.trim(),
|
||||||
|
api_key: apiKey.trim(),
|
||||||
|
base_url: baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMessage(null);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
await save(values);
|
||||||
|
setSaveMessage("Credentials saved.");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(errorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const user = await verifyE621Credentials({ ...values, configured: true });
|
||||||
|
setTestResult({
|
||||||
|
ok: true,
|
||||||
|
message: `Connected as ${user.name}${
|
||||||
|
typeof user.favorite_count === "number"
|
||||||
|
? ` — ${user.favorite_count} favorites`
|
||||||
|
: ""
|
||||||
|
}.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setTestResult({ ok: false, message: e621ErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
e621 credentials
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-ctp-overlay0">
|
||||||
|
Generate an API key under Account → My profile on e621, then paste it
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full border px-2.5 py-1 font-mono text-[11px]",
|
||||||
|
credentials?.configured
|
||||||
|
? "border-ctp-green/40 bg-ctp-green/15 text-ctp-green"
|
||||||
|
: "border-ctp-surface1 text-ctp-overlay0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{credentials?.configured ? "Configured" : "Not configured"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col gap-4">
|
||||||
|
<Field label="e621 username">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="API key">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={cn(inputClass, "pr-10 font-mono")}
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((visible) => !visible)}
|
||||||
|
title={showKey ? "Hide API key" : "Show API key"}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
{showKey ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="API base URL">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={baseChoice}
|
||||||
|
onChange={(event) => setBaseChoice(event.target.value)}
|
||||||
|
>
|
||||||
|
{BASE_URL_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{baseChoice === "custom" ? (
|
||||||
|
<input
|
||||||
|
className={cn(inputClass, "font-mono")}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={customBase}
|
||||||
|
onChange={(event) => setCustomBase(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{saveError ? (
|
||||||
|
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{saveMessage ? (
|
||||||
|
<p className="text-sm text-ctp-green">{saveMessage}</p>
|
||||||
|
) : null}
|
||||||
|
{testResult ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
|
||||||
|
testResult.ok
|
||||||
|
? "border-ctp-green/30 bg-ctp-green/10 text-ctp-green"
|
||||||
|
: "border-ctp-red/30 bg-ctp-red/10 text-ctp-red",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{testResult.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void handleTest()}
|
||||||
|
disabled={testing || !values.username || !values.api_key}
|
||||||
|
>
|
||||||
|
{testing ? "Testing…" : "Test connection"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-ctp-overlay0">
|
||||||
|
Stored on your server and fetched by this app when needed. e621 requests
|
||||||
|
are made directly from your browser, throttled to one per second.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-semibold">Account</h1>
|
||||||
|
{user ? (
|
||||||
|
<p className="mt-1 text-sm text-ctp-overlay0">
|
||||||
|
Signed in as {user.username}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
{loading && !credentials ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AccountForm credentials={credentials} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = queue.then(task, task);
|
||||||
|
queue = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function throttle(): Promise<void> {
|
||||||
|
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<string, string | number | boolean | undefined>;
|
||||||
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
body?: BodyInit;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function e621Request<T>(
|
||||||
|
credentials: E621Credentials,
|
||||||
|
path: string,
|
||||||
|
options: E621RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<E621User> {
|
||||||
|
const data = await e621Request<{ user?: E621User } & Partial<E621User>>(
|
||||||
|
credentials,
|
||||||
|
"/users/me.json",
|
||||||
|
);
|
||||||
|
return data.user ?? (data as E621User);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
import { api, getToken, setToken } from "@/lib/api";
|
import { api, getToken, setToken } from "@/lib/api";
|
||||||
import type { User } from "@/lib/types";
|
import type { User } from "@/lib/types";
|
||||||
|
import { useE621 } from "@/store/e621";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -42,6 +43,7 @@ export const useAuth = create<AuthState>((set) => ({
|
|||||||
// The token may already be invalid; clearing it locally is enough.
|
// The token may already be invalid; clearing it locally is enough.
|
||||||
}
|
}
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
useE621.getState().clear();
|
||||||
set({ user: null, ready: true });
|
set({ user: null, ready: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
save: (values: {
|
||||||
|
username: string;
|
||||||
|
api_key: string;
|
||||||
|
base_url: string;
|
||||||
|
}) => Promise<E621Credentials>;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useE621 = create<E621State>((set) => ({
|
||||||
|
credentials: null,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const credentials = await api<E621Credentials>("/api/auth/e621/");
|
||||||
|
set({ credentials, loading: false });
|
||||||
|
} catch {
|
||||||
|
set({ credentials: null, loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
save: async (values) => {
|
||||||
|
const credentials = await api<E621Credentials>("/api/auth/e621/", {
|
||||||
|
method: "PUT",
|
||||||
|
json: values,
|
||||||
|
});
|
||||||
|
set({ credentials });
|
||||||
|
return credentials;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => set({ credentials: null }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user