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:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
setCheckErrors((current) => {
|
||||
const next = { ...current };
|
||||
for (const id of ids) delete next[id];
|
||||
return next;
|
||||
});
|
||||
setIqdbError(null);
|
||||
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;
|
||||
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 };
|
||||
delete next[tempId];
|
||||
return next;
|
||||
});
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user