From b96c31123503ff229aca45b2e637c3f95eb4e21f Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Sat, 19 Sep 2026 00:30:42 -0500 Subject: [PATCH] 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/ (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. --- backend/config/settings.py | 4 +- deploy/J621-Frontend | 2 + deploy/push_frontend.sh | 1 + frontend/src/components/MediaCard.tsx | 6 +- frontend/src/features/library/DeletePage.tsx | 4 +- frontend/src/features/library/IqdbCard.tsx | 4 +- .../src/features/optimize/OptimizeModal.tsx | 11 +- frontend/src/features/similar/SimilarPage.tsx | 6 +- .../src/features/upload/BulkRateModal.tsx | 6 +- frontend/src/features/upload/UploadPage.tsx | 134 +++++++++++++----- frontend/src/globals.d.ts | 2 + frontend/src/lib/api.ts | 16 ++- frontend/src/lib/e621.ts | 30 +++- frontend/vite.config.ts | 20 +++ 14 files changed, 184 insertions(+), 62 deletions(-) create mode 100644 frontend/src/globals.d.ts diff --git a/backend/config/settings.py b/backend/config/settings.py index 21286d7..488ab74 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -206,7 +206,9 @@ WATCHED_FOLDER = str(WATCHED_FOLDER) # e621 integration E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/") -USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)") +# e621 asks for "Application name/version (developer)". Browser clients cannot +# set a User-Agent, so the SPA sends the same string in its `_client` parameter. +USER_AGENT = os.getenv("USER_AGENT", f"J621/{GIT_COMMIT_HASH} (JakeBreath)") # Hosts the client-download proxy is allowed to stream from. E621_MEDIA_HOSTS = [ host.strip() diff --git a/deploy/J621-Frontend b/deploy/J621-Frontend index 79610c0..598ea8e 100644 --- a/deploy/J621-Frontend +++ b/deploy/J621-Frontend @@ -10,6 +10,8 @@ # syntax=docker/dockerfile:1 FROM node:22-alpine AS build +ARG GIT_HASH=dev +ENV GIT_HASH=$GIT_HASH WORKDIR /app COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci --no-audit --no-fund diff --git a/deploy/push_frontend.sh b/deploy/push_frontend.sh index fd69d38..97ea80f 100755 --- a/deploy/push_frontend.sh +++ b/deploy/push_frontend.sh @@ -26,6 +26,7 @@ fi echo "==> Building + pushing $PLATFORMS -> $REGISTRY:{latest,$SHA} ..." docker buildx build --builder "$BUILDER" --push \ --platform "$PLATFORMS" \ + --build-arg "GIT_HASH=$SHA" \ -f deploy/J621-Frontend \ -t "$REGISTRY:latest" \ -t "$REGISTRY:$SHA" \ diff --git a/frontend/src/components/MediaCard.tsx b/frontend/src/components/MediaCard.tsx index e8d410a..40e978c 100644 --- a/frontend/src/components/MediaCard.tsx +++ b/frontend/src/components/MediaCard.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router-dom"; +import { apiUrl } from "@/lib/api"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/format"; import type { MediaItem } from "@/lib/types"; @@ -17,8 +18,9 @@ const ratingLabels: Record = { }; export function MediaCard({ item }: { item: MediaItem }) { - const preview = - item.kind === "video" ? item.thumbnail_url : item.raw_url; + const preview = apiUrl( + item.kind === "video" ? item.thumbnail_url : item.raw_url, + ); const rating = item.display_rating; return ( diff --git a/frontend/src/features/library/DeletePage.tsx b/frontend/src/features/library/DeletePage.tsx index 9372e9b..00479a7 100644 --- a/frontend/src/features/library/DeletePage.tsx +++ b/frontend/src/features/library/DeletePage.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; -import { api, errorMessage } from "@/lib/api"; +import { api, apiUrl, errorMessage } from "@/lib/api"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/format"; import type { MediaItem, Paginated, StorageInfo } from "@/lib/types"; @@ -245,7 +245,7 @@ export default function DeletePage() { >
{item.filename}({ ...DEFAULT_OPTIONS, maxDimension: null, @@ -180,7 +183,7 @@ export function OptimizeModal({ const sniffQuery = useQuery({ queryKey: ["image-kind", item.j_id, item.updated_at], queryFn: async () => { - const response = await fetch(item.raw_url, { + const response = await fetch(fileUrl, { headers: { Range: "bytes=0-4095" }, }); if (!response.ok && response.status !== 206) { @@ -315,7 +318,7 @@ export function OptimizeModal({ if (result?.storageKey) void deleteStoredFile(result.storageKey); setResultInfo(null); run({ - url: item.raw_url, + url: fileUrl, filename: item.filename, kind, options, @@ -366,7 +369,7 @@ export function OptimizeModal({
{exact.thumbnail_url ? ( {exact.j_id} @@ -309,7 +309,7 @@ export default function SimilarPage() { {match.thumbnail_url ? ( {match.j_id} {uploads.map((temp) => { const isSelected = selected.has(temp.temp_id); - const preview = temp.preview_url ?? temp.file_url; + const preview = apiUrl( + temp.preview_url ?? temp.file_url ?? "", + ); return ( +
+ ) : busy || processing ? ( +

{busy ? `Uploading ${Math.min(uploadedCount + 1, entries.length)}/${entries.length}…` : iqdbProgress - ? `Checking IQDB — ${iqdbProgress.done}/${iqdbProgress.total}` + ? `Checking IQDB — ${iqdbProgress.done}/${iqdbProgress.total}${ + currentCheck ? ` · ${currentCheck}` : "" + }` : "Processing uploads…"}

diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts new file mode 100644 index 0000000..9962902 --- /dev/null +++ b/frontend/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Baked at build time by Vite (see vite.config.ts). */ +declare const __GIT_HASH__: string; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 271387e..91e0aaf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,8 +5,20 @@ const TOKEN_KEY = "j621.token"; /** Absolutise a backend path; external/absolute URLs pass through. */ export function apiUrl(path: string): string { - if (!path.startsWith("/")) return path; - return `${getBackendUrl()}${path}`; + 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 { diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts index 9b590d7..fcecd60 100644 --- a/frontend/src/lib/e621.ts +++ b/frontend/src/lib/e621.ts @@ -82,13 +82,21 @@ export function effectiveCredentials( return credentials ?? ANONYMOUS_CREDENTIALS; } -const CLIENT_VERSION = "J621/0.1"; +// e621 asks for "Application name/version (developer)". Browsers cannot set a +// User-Agent, so the same string is sent in the `_client` parameter instead. +// `__GIT_HASH__` is replaced by Vite at build time; guard it so the dev +// server (which may not substitute it) still works. +const GIT_HASH = typeof __GIT_HASH__ === "string" ? __GIT_HASH__ : "dev"; +const CLIENT_VERSION = `J621/${GIT_HASH} (JakeBreath)`; // 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(); +/** A hung request would block the whole serialized queue forever. */ +const REQUEST_TIMEOUT_MS = 20_000; + function schedule(task: () => Promise): Promise { const run = queue.then(task, task); queue = run.then( @@ -124,10 +132,7 @@ export function e621Request( 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)`, - ); + url.searchParams.set("_client", CLIENT_VERSION); for (const [key, value] of Object.entries(options.params ?? {})) { if (value !== undefined) { url.searchParams.set(key, String(value)); @@ -143,12 +148,20 @@ export function e621Request( } const startedAt = performance.now(); + const controller = new AbortController(); + const timer = setTimeout( + () => + controller.abort(new DOMException("e621 request timed out", "TimeoutError")), + REQUEST_TIMEOUT_MS, + ); + const forwardAbort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", forwardAbort, { once: true }); try { const response = await fetch(url, { method: options.method ?? "GET", headers, body: options.body, - signal: options.signal, + signal: controller.signal, }); if (!response.ok) { @@ -165,6 +178,8 @@ export function e621Request( } return (await response.json()) as T; } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", forwardAbort); useE621Metrics .getState() .record(path, performance.now() - startedAt); @@ -190,6 +205,9 @@ export function e621ErrorMessage(error: unknown): string { } return `e621 request failed (${error.status}).`; } + if (error instanceof DOMException && error.name === "TimeoutError") { + return "e621 did not answer in time."; + } if (error instanceof TypeError) { return "Could not reach e621. Check the base URL and your connection."; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5ac699a..5dc1461 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { fileURLToPath, URL } from "node:url"; import tailwindcss from "@tailwindcss/vite"; @@ -6,9 +7,28 @@ import { defineConfig } from "vite"; const backend = process.env.VITE_BACKEND ?? "http://127.0.0.1:8000"; +/** Short commit hash baked into the bundle (e621 `_client` string). */ +function gitHash(): string { + const configured = process.env.GIT_HASH?.trim(); + if (configured) return configured; + try { + return execSync("git rev-parse --short HEAD", { + cwd: fileURLToPath(new URL(".", import.meta.url)), + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + return "dev"; + } +} + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + __GIT_HASH__: JSON.stringify(gitHash()), + }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)),