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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user