declare global { interface Window { __IMAGEFIND_BASE__?: string; } } function normaliseBase(value: string | undefined) { const base = value?.trim() || "/"; return `/${base.replace(/^\/+|\/+$/g, "")}${base === "/" ? "" : "/"}`; } export const appBase = normaliseBase(window.__IMAGEFIND_BASE__); export function appUrl(value: string | undefined): string { if (!value) return ""; if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value; const target = `${appBase}${value.replace(/^\/+/, "")}`; const path = `/${value.replace(/^\/+/, "")}`; const mediaPath = /^\/api\/v1\/(?:frames\/[^/]+\/thumbnail|faces\/[^/]+\/thumbnail|videos\/[^/]+\/(?:stream|download)|previews\/)/.test(path); if (appBase === "/" || !gatewayMediaToken || !mediaPath) return target; return `${target}${target.includes("?") ? "&" : "?"}media_token=${encodeURIComponent(gatewayMediaToken)}`; } let csrfToken = sessionStorage.getItem("imagefind:csrf") || ""; let gatewaySessionToken = sessionStorage.getItem("imagefind:gateway-session") || ""; let gatewayMediaToken = sessionStorage.getItem("imagefind:gateway-media-token") || ""; let gatewayRenewal: Promise | null = null; const inflightReads = new Map>(); export function appMediaUrl(value: string | undefined): string { if (!value) return ""; if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value; const target = appUrl(value); if (appBase === "/" || !gatewayMediaToken) return target; const hashAt = target.indexOf("#"); const body = hashAt >= 0 ? target.slice(0, hashAt) : target; const hash = hashAt >= 0 ? target.slice(hashAt) : ""; if (/(?:^|[?&])media_token=/.test(body)) return target; const separator = body.includes("?") ? "&" : "?"; return `${body}${separator}media_token=${encodeURIComponent(gatewayMediaToken)}${hash}`; } export function setCsrf(token: string) { csrfToken = token; if (token) sessionStorage.setItem("imagefind:csrf", token); else sessionStorage.removeItem("imagefind:csrf"); } export function setGatewaySession(token: string) { gatewaySessionToken = token; if (token) sessionStorage.setItem("imagefind:gateway-session", token); else sessionStorage.removeItem("imagefind:gateway-session"); } export function setGatewayMediaToken(token: string) { gatewayMediaToken = token; if (token) sessionStorage.setItem("imagefind:gateway-media-token", token); else sessionStorage.removeItem("imagefind:gateway-media-token"); } async function renewGatewaySession(): Promise { if (appBase === "/") throw new Error("需要管理员登录"); if (!gatewayRenewal) { gatewayRenewal = fetch(appUrl("/api/v1/auth/gateway"), { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: "{}", }).then(async response => { if (!response.ok) throw new Error("飞牛管理员会话需要重新建立"); const value = await response.json() as { csrf_token: string; gateway_session_token: string; gateway_media_token: string; }; setGatewaySession(value.gateway_session_token); setGatewayMediaToken(value.gateway_media_token); setCsrf(value.csrf_token); window.dispatchEvent(new CustomEvent("imagefind-gateway-renewed")); }).finally(() => { gatewayRenewal = null; }); } return gatewayRenewal; } async function request(path: string, options: RequestInit = {}): Promise { const send = () => { const headers = new Headers(options.headers); if (typeof options.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); if (options.method && !["GET", "HEAD"].includes(options.method.toUpperCase()) && csrfToken) { headers.set("X-CSRF-Token", csrfToken); } if (appBase !== "/" && gatewaySessionToken) headers.set("X-ImageFind-Gateway-Session", gatewaySessionToken); return fetch(appUrl(`/api/v1${path}`), { ...options, headers, credentials: "same-origin" }); }; let response = await send(); if (response.status === 401 && appBase !== "/" && path !== "/auth/gateway") { await renewGatewaySession(); response = await send(); } if (!response.ok) { let message = `${response.status} ${response.statusText}`; try { const payload = await response.json(); message = typeof payload.detail === "string" ? payload.detail : payload.detail?.message || message; } catch { /* response is not JSON */ } throw new Error(message); } return response; } export async function api(path: string, options: RequestInit = {}): Promise { const method = String(options.method || "GET").toUpperCase(); if (method === "GET" && options.body === undefined) { const existing = inflightReads.get(path); if (existing) return existing as Promise; const pending = request(path, options).then(response => response.status === 204 ? undefined : response.json() ).finally(() => inflightReads.delete(path)); inflightReads.set(path, pending); return pending as Promise; } const response = await request(path, options); if (response.status === 204) return undefined as T; return response.json() as Promise; } export async function downloadBackup(scope: "keys" | "full", password: string) { const response = await request("/backups/export", { method: "POST", body: JSON.stringify({ scope, password }), }); const disposition = response.headers.get("Content-Disposition") || ""; const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1]; const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${scope}.ifbackup`; const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); return { filename, size_bytes: blob.size }; } export async function downloadGeneratedBackup(exportId: string) { const response = await request(`/backups/${encodeURIComponent(exportId)}/download`); const disposition = response.headers.get("Content-Disposition") || ""; const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1]; const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${exportId}.ifbackup`; const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); return { filename, size_bytes: blob.size }; } export async function restoreBackup(file: File, password: string, confirmed: boolean): Promise { const form = new FormData(); form.append("file", file); form.append("password", password); form.append("confirmed", String(confirmed)); return api("/backups/restore", { method: "POST", body: form }); } export async function uploadImage(file: File): Promise<{ id: string }> { const form = new FormData(); form.append("file", file); return api("/query-images", { method: "POST", body: form }); }