feat: add ImageFind application and release pipelines
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
}
|
||||
@@ -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
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user