feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+1658
View File
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
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 });
}
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { appBase } from "./api";
import "./styles.css";
document.documentElement.dataset.imagefindAccess=appBase==="/"?"direct":"gateway";
function syncVisualViewport(){
const height=Math.round(window.visualViewport?.height||window.innerHeight);
document.documentElement.style.setProperty("--imagefind-visual-height",`${height}px`);
document.documentElement.style.setProperty("--imagefind-sheet-height",`${Math.round(height*.82)}px`);
}
syncVisualViewport();
window.addEventListener("resize",syncVisualViewport,{passive:true});
window.visualViewport?.addEventListener("resize",syncVisualViewport,{passive:true});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />