179 lines
7.3 KiB
TypeScript
179 lines
7.3 KiB
TypeScript
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<void> | null = null;
|
|
const inflightReads = new Map<string, Promise<unknown>>();
|
|
|
|
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<void> {
|
|
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<Response> {
|
|
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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const method = String(options.method || "GET").toUpperCase();
|
|
if (method === "GET" && options.body === undefined) {
|
|
const existing = inflightReads.get(path);
|
|
if (existing) return existing as Promise<T>;
|
|
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<T>;
|
|
}
|
|
const response = await request(path, options);
|
|
if (response.status === 204) return undefined as T;
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
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<T>(file: File, password: string, confirmed: boolean): Promise<T> {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("password", password);
|
|
form.append("confirmed", String(confirmed));
|
|
return api<T>("/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 });
|
|
}
|