Files
J621/frontend/src/lib/api.ts
T
JakeBreath b96c311235 Fix cross-origin staging URLs, e621 UA format and IQDB queue stalls
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.

apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.

e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).

IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.

Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
2026-09-19 00:30:42 -05:00

153 lines
4.4 KiB
TypeScript

import type { TempUpload } from "@/lib/types";
import { getBackendUrl, hasBackend } from "@/lib/backend";
const TOKEN_KEY = "j621.token";
/** Absolutise a backend path; external/absolute URLs pass through. */
export function apiUrl(path: string): string {
if (path.startsWith("/")) return `${getBackendUrl()}${path}`;
// Absolute URL built by the API. In same-origin mode (the dev proxy, the
// single deploy nginx) keep it on the page's origin: the proxy serves
// /api too, and auth'd fetches like the IQDB staging blob would otherwise
// need CORS. With an explicit backend URL, leave it pointing there.
if (!getBackendUrl()) {
try {
const url = new URL(path);
return `${url.pathname}${url.search}`;
} catch {
return path;
}
}
return path;
}
export function getToken(): string | null {
return window.localStorage.getItem(TOKEN_KEY);
}
export function authHeaders(extra?: HeadersInit): Headers {
const headers = new Headers(extra);
const token = getToken();
if (token) headers.set("Authorization", `Token ${token}`);
return headers;
}
export function setToken(token: string | null): void {
if (token) {
window.localStorage.setItem(TOKEN_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
}
export class ApiError extends Error {
status: number;
data: unknown;
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export function errorMessage(error: unknown): string {
if (!(error instanceof ApiError)) {
return error instanceof Error ? error.message : "Something went wrong.";
}
const data = error.data;
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
if (typeof record.detail === "string") return record.detail;
for (const [field, value] of Object.entries(record)) {
if (Array.isArray(value) && value.length > 0) {
return `${field}: ${String(value[0])}`;
}
if (typeof value === "string") {
return `${field}: ${value}`;
}
}
}
return `Request failed (${error.status}).`;
}
interface ApiOptions extends Omit<RequestInit, "body"> {
json?: unknown;
body?: BodyInit;
}
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
if (!hasBackend()) {
throw new ApiError(0, {
detail: "No backend is connected — set one up to use this feature.",
});
}
const { json, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
let body = options.body;
if (json !== undefined) {
finalHeaders.set("Content-Type", "application/json");
body = JSON.stringify(json);
}
const token = getToken();
if (token) finalHeaders.set("Authorization", `Token ${token}`);
const response = await fetch(apiUrl(path), { ...rest, headers: finalHeaders, body });
if (!response.ok) {
let data: unknown = null;
try {
data = await response.json();
} catch {
data = null;
}
throw new ApiError(response.status, data);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function uploadFile<T = TempUpload>(
file: File,
fields: Record<string, string> = {},
onProgress?: (percent: number) => void,
path = "/api/uploads/",
): Promise<T> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file);
for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}
const request = new XMLHttpRequest();
request.open("POST", apiUrl(path));
const token = getToken();
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
request.addEventListener("load", () => {
let data: unknown = null;
try {
data = JSON.parse(request.responseText);
} catch {
data = null;
}
if (request.status >= 200 && request.status < 300) {
resolve(data as T);
} else {
reject(new ApiError(request.status, data));
}
});
request.addEventListener("error", () => {
reject(new ApiError(0, { detail: "Network error" }));
});
request.send(form);
});
}