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.
This commit is contained in:
2026-09-19 00:30:42 -05:00
parent 2f613b7027
commit b96c311235
14 changed files with 184 additions and 62 deletions
+3 -1
View File
@@ -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()
+2
View File
@@ -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
+1
View File
@@ -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" \
+4 -2
View File
@@ -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<string, string> = {
};
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 (
+2 -2
View File
@@ -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() {
>
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<img
src={item.thumbnail_url}
src={apiUrl(item.thumbnail_url)}
alt={item.filename}
loading="lazy"
className="h-full w-full object-cover"
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Link } from "react-router-dom";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { api, apiUrl, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
@@ -44,7 +44,7 @@ export function IqdbCard({
setSelected(null);
setCandidates(null);
try {
const response = await fetch(item.raw_url);
const response = await fetch(apiUrl(item.raw_url));
if (!response.ok) {
throw new Error(`Could not load the file (HTTP ${response.status}).`);
}
@@ -3,7 +3,7 @@ import { X } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Button, Spinner, inputClass } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api";
import { apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
@@ -171,6 +171,9 @@ export function OptimizeModal({
const queryClient = useQueryClient();
const extensionKind: OptimizeKind =
kindForExtension(item.extension) ?? "image";
// Same-origin through the proxy in dev; the worker and the range requests
// cannot use a cross-origin URL without CORS.
const fileUrl = apiUrl(item.raw_url);
const [options, setOptions] = useState<OptimizeOptions>({
...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({
<div className="grid gap-3 sm:grid-cols-2">
<PreviewPanel
title="Original"
url={item.raw_url}
url={fileUrl}
isVideo={isVideo}
size={originalSize}
state="original"
@@ -10,7 +10,7 @@ import { Link } from "react-router-dom";
import { ResponsivePanel } from "@/components/BottomSheet";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage, uploadFile } from "@/lib/api";
import { api, apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
@@ -257,7 +257,7 @@ export default function SimilarPage() {
>
{exact.thumbnail_url ? (
<img
src={exact.thumbnail_url}
src={apiUrl(exact.thumbnail_url)}
alt={exact.j_id}
className="h-14 w-14 rounded object-cover"
/>
@@ -309,7 +309,7 @@ export default function SimilarPage() {
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{match.thumbnail_url ? (
<img
src={match.thumbnail_url}
src={apiUrl(match.thumbnail_url)}
alt={match.j_id}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
@@ -3,7 +3,7 @@ import { Check, Video, X } from "lucide-react";
import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { api, apiUrl, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { TempUpload } from "@/lib/types";
import { toast } from "@/store/toasts";
@@ -235,7 +235,9 @@ export function BulkRateModal({
>
{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 (
<button
key={temp.temp_id}
+93 -35
View File
@@ -91,13 +91,34 @@ function describeError(error: unknown): string {
}
async function fetchTempBlob(url: string): Promise<Blob> {
const response = await fetch(url, { headers: authHeaders() });
const response = await fetch(apiUrl(url), { headers: authHeaders() });
if (!response.ok) {
throw new Error(`Could not read the staged file (${response.status}).`);
}
return response.blob();
}
/** Staged uploads that still need an IQDB pass (image-only). */
function unresolvedIqdbIds(list: TempUpload[]): string[] {
return list
.filter(
(temp) =>
(temp.status === "pending" || temp.status === "visual_match") &&
temp.iqdb_data === null &&
!/\.(mp4|webm)$/i.test(temp.original_filename),
)
.map((temp) => temp.temp_id);
}
/** e621 failures that mean "stop the queue", not "skip this file". */
function isFatalIqdbError(error: unknown): boolean {
if (error instanceof E621Error) {
return [401, 403, 429, 503].includes(error.status);
}
// Network/CORS failures and aborted (timed out) requests.
return error instanceof TypeError || error instanceof DOMException;
}
function TempCard({
temp,
checking,
@@ -112,7 +133,7 @@ function TempCard({
onDismiss: () => void;
}) {
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
const preview = temp.preview_url ?? temp.file_url;
const preview = apiUrl(temp.preview_url ?? temp.file_url ?? "");
return (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5">
@@ -239,6 +260,7 @@ function MetadataModal({
const [error, setError] = useState<string | null>(null);
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
const mediaUrl = apiUrl(temp.preview_url ?? temp.file_url ?? "");
async function run(task: () => Promise<void>) {
setBusy(true);
@@ -340,17 +362,17 @@ function MetadataModal({
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="w-fit max-w-full overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
{(temp.preview_url ?? temp.file_url) ? (
{mediaUrl ? (
isVideo ? (
<video
src={(temp.preview_url ?? temp.file_url) as string}
src={mediaUrl}
controls
muted
className="block h-auto max-h-[55vh] w-auto max-w-full"
/>
) : (
<img
src={(temp.preview_url ?? temp.file_url) as string}
src={mediaUrl}
alt={temp.original_filename}
className="block h-auto max-h-[55vh] w-auto max-w-full"
/>
@@ -672,6 +694,8 @@ export default function UploadPage() {
const inputRef = useRef<HTMLInputElement>(null);
const queueRef = useRef<UploadEntry[]>([]);
const runningRef = useRef(false);
const iqdbQueueRef = useRef<string[]>([]);
const iqdbRunningRef = useRef(false);
const objectUrls = useRef<string[]>([]);
const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]);
@@ -679,6 +703,8 @@ export default function UploadPage() {
const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [checkErrors, setCheckErrors] = useState<Record<string, string>>({});
const [iqdbError, setIqdbError] = useState<string | null>(null);
const [currentCheck, setCurrentCheck] = useState<string | null>(null);
const [iqdbProgress, setIqdbProgress] = useState<{
done: number;
total: number;
@@ -779,31 +805,48 @@ export default function UploadPage() {
return fresh;
}
async function checkSimilarity(ids: string[]) {
if (ids.length === 0) return;
// One serial drain for all IQDB checks: clicking "Check similarity" many
// times used to start overlapping runs that re-downloaded the same staging
// blobs and piled requests onto the e621 queue until everything stalled.
function checkSimilarity(ids: string[]) {
const queued = new Set(iqdbQueueRef.current);
const added = ids.filter((id) => !queued.has(id));
if (added.length === 0) return;
iqdbQueueRef.current.push(...added);
void drainIqdbChecks();
}
async function drainIqdbChecks() {
if (iqdbRunningRef.current) return;
iqdbRunningRef.current = true;
setProcessing(true);
setCheckingIds((current) => [...current, ...ids]);
setIqdbError(null);
try {
while (iqdbQueueRef.current.length > 0) {
const tempId = iqdbQueueRef.current.shift() as string;
const cached =
queryClient.getQueryData<TempUpload[]>(["uploads"]) ?? [];
const temp = cached.find((item) => item.temp_id === tempId);
// Skip files that vanished, got resolved meanwhile, or are videos.
if (
!temp ||
!temp.file_url ||
(temp.status !== "pending" && temp.status !== "visual_match") ||
/\.(mp4|webm)$/i.test(temp.original_filename)
) {
continue;
}
setCheckingIds((current) => [...current, tempId]);
setCheckErrors((current) => {
const next = { ...current };
for (const id of ids) delete next[id];
delete next[tempId];
return next;
});
try {
const fresh = await refreshUploads();
// Local visual matches still get an IQDB pass: the library check only
// proves near-duplicates, IQDB finds the actual post.
const targets = fresh.filter(
(temp) =>
ids.includes(temp.temp_id) &&
(temp.status === "pending" || temp.status === "visual_match"),
);
for (const temp of targets) {
if (!temp.file_url) continue;
if (/\.(mp4|webm)$/i.test(temp.original_filename)) continue;
setIqdbProgress((current) => ({
done: current?.done ?? 0,
total: (current?.total ?? 0) + 1,
}));
setCurrentCheck(temp.original_filename);
try {
const blob = await fetchTempBlob(temp.file_url);
const results = await iqdbSearch(
@@ -827,18 +870,28 @@ export default function UploadPage() {
} catch (error) {
setCheckErrors((current) => ({
...current,
[temp.temp_id]: describeError(error),
[tempId]: describeError(error),
}));
// Auth, rate limit, timeout or network problems would doom every
// remaining file; stop and let the user retry once e621 answers.
if (isFatalIqdbError(error)) {
setIqdbError(describeError(error));
iqdbQueueRef.current = [];
setIqdbProgress(null);
break;
}
} finally {
setCurrentCheck(null);
setIqdbProgress((current) =>
current ? { ...current, done: current.done + 1 } : current,
);
setCheckingIds((current) =>
current.filter((value) => value !== temp.temp_id),
current.filter((value) => value !== tempId),
);
}
}
} finally {
iqdbRunningRef.current = false;
setProcessing(false);
setCheckingIds([]);
setIqdbProgress((current) =>
@@ -883,15 +936,7 @@ export default function UploadPage() {
// 2) IQDB similarity for every unresolved file that has not been checked
// yet (pending and local visual matches alike; IQDB is image-only).
const fresh = await refreshUploads();
const unresolvedIds = fresh
.filter(
(temp) =>
(temp.status === "pending" || temp.status === "visual_match") &&
temp.iqdb_data === null &&
!/\.(mp4|webm)$/i.test(temp.original_filename),
)
.map((temp) => temp.temp_id);
void checkSimilarity(unresolvedIds);
void checkSimilarity(unresolvedIqdbIds(fresh));
}
async function drainQueue() {
@@ -1023,15 +1068,28 @@ export default function UploadPage() {
then moved into the library once resolved.
</p>
</div>
{busy || processing ? (
<div className="flex w-56 flex-col gap-1.5">
{iqdbError ? (
<div className="flex w-64 flex-col items-end gap-1 text-right">
<p className="text-xs text-ctp-red">IQDB checks paused: {iqdbError}</p>
<button
type="button"
onClick={() => void checkSimilarity(unresolvedIqdbIds(uploads))}
className="rounded-md border border-ctp-surface1 px-2 py-0.5 font-mono text-[10px] text-ctp-subtext0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
retry checks
</button>
</div>
) : busy || processing ? (
<div className="flex w-64 flex-col gap-1.5">
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{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…"}
</span>
</p>
+2
View File
@@ -0,0 +1,2 @@
/** Baked at build time by Vite (see vite.config.ts). */
declare const __GIT_HASH__: string;
+14 -2
View File
@@ -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 {
+24 -6
View File
@@ -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<unknown> = Promise.resolve();
/** A hung request would block the whole serialized queue forever. */
const REQUEST_TIMEOUT_MS = 20_000;
function schedule<T>(task: () => Promise<T>): Promise<T> {
const run = queue.then(task, task);
queue = run.then(
@@ -124,10 +132,7 @@ export function e621Request<T>(
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<T>(
}
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<T>(
}
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.";
}
+20
View File
@@ -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)),