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)),