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:
2026-09-17 08:49:00 -05:00
parent a09ab8a160
commit b6409523e6
12 changed files with 555 additions and 6 deletions
+144
View File
@@ -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);
}