diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index bd184e0f..5de771e2 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -17,6 +17,7 @@ import type { EventBusPort } from "./http/sse.ts"; import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts"; import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts"; import type { OutboxPort, ServerContext, ServerModule, ServerRole } from "./modules/types.ts"; +import { configureStorage } from "./storage/registry.ts"; // 进程装配:ServerContext、任务注册表、outbox 路由。api / worker / all 三种角色共用。 @@ -200,6 +201,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise { // 授权(automation_grant / approval + 自动化总开关)、审计(audit_event)、幂等键与操作账本在这里注入 capabilities: createCapabilityRegistry({ logger, db, settings }), }; + configureStorage(ctx); const tasks = buildTaskRegistry(opts.modules, ctx); for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx); ctx.hooks = buildHookRegistry(opts.modules, ctx); diff --git a/apps/server/src/modules/auth-settings/index.ts b/apps/server/src/modules/auth-settings/index.ts index a20f3fdb..584bb9c9 100644 --- a/apps/server/src/modules/auth-settings/index.ts +++ b/apps/server/src/modules/auth-settings/index.ts @@ -7,6 +7,7 @@ import { registerEngineRoutes } from "./engine-routes.ts"; import { registerInstanceRoutes } from "./instances-routes.ts"; import { registerKeyRoutes } from "./keys-routes.ts"; import { registerSettingsRoutes } from "./settings-routes.ts"; +import { registerStorageRoutes } from "./storage-routes.ts"; /** * W2-A 认证/设置/实例/引擎面板:管理员登录门禁、settings、api_key、secret 加密服务、 @@ -38,6 +39,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}): registerSettingsRoutes(app, ctx); registerKeyRoutes(app, ctx); registerInstanceRoutes(app, ctx); + registerStorageRoutes(app, ctx); registerEngineRoutes(app, ctx, opts.engineSchema ? { schema: opts.engineSchema } : {}); if (opts.setupTokenOnStart !== false && ctx.db) { ensureSetupToken(ctx).catch((err) => ctx.logger.error({ err }, "生成 setup token 失败")); diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts index 4d0e6249..c3eb1447 100644 --- a/apps/server/src/modules/auth-settings/instances-routes.ts +++ b/apps/server/src/modules/auth-settings/instances-routes.ts @@ -25,6 +25,8 @@ import { import { z } from "zod"; import { ApiException } from "../../http/errors.ts"; import { redact, redactString } from "../../logger.ts"; +import { isRemotePath, vcheckRoot } from "../../storage/vfs.ts"; +import { parseVPath } from "../../storage/vpath.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { afterCursor, @@ -184,6 +186,17 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) { // ── library_root ── const sentinel = async (localPath: string, file: string | null | undefined) => { + // Alist 库根(/@alist/<存储 id>/<路径>):不写哨兵(同 MP),只确认目录存在且存储可连 + if (isRemotePath(localPath)) { + if (!parseVPath(localPath)) throw new ApiException("validation_failed", "Alist 路径格式不对"); + const h = await vcheckRoot(localPath); + if (h.kind !== "online") + throw new ApiException( + "precondition_failed", + `Alist 目录不可用(${h.reason}),先在 Alist 里建好目录并测试存储连接`, + ); + return null; + } const name = file || DEFAULT_ROOT_SENTINEL; try { const st = await stat(localPath); @@ -229,7 +242,9 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) { test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"), async ({ params, actor }) => { const row = await getRow("library_root", params.id); - const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL); + const h = isRemotePath(String(row.local_path)) + ? await vcheckRoot(String(row.local_path)) + : await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL); const ok = h.kind === "online"; await update("library_root", row.id, { health_status: ok ? "healthy" : "offline", diff --git a/apps/server/src/modules/auth-settings/storage-routes.ts b/apps/server/src/modules/auth-settings/storage-routes.ts new file mode 100644 index 00000000..6f3d72de --- /dev/null +++ b/apps/server/src/modules/auth-settings/storage-routes.ts @@ -0,0 +1,264 @@ +import { AlistError, normAlistPath, redactAlistText } from "@umt/adapters"; +import { + Api, + StorageInstance, + StorageInstanceCreate, + StorageInstanceUpdate, + type StorageTestResult, + secretInput, +} from "@umt/contracts"; +import { ApiException } from "../../http/errors.ts"; +import { buildClient, clearStorageCache, configureStorage, resolvePlain } from "../../storage/registry.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { + afterCursor, + audit, + crudDefs, + cursorCol, + decodeCursor, + endpoint, + pageOf, + pgConflict, + toApi, + toDb, +} from "./endpoint.ts"; + +// 存储实例(Alist / OpenList)CRUD + 测试连接(docs/design/storage-alist.md §3、§4)。 +// 凭据(令牌 / 密码)与路径密码只写不读:省略或空串 = 不变,null = 清除;明文只在 secret 表里加密保存。 + +const JSON_COLS = ["options", "health_detail", "version_info"] as const; +const tags = ["storage-instances"]; + +type Row = Record & { + id: string; + secret_ref: string | null; + path_password_ref: string | null; + auth_kind: "token" | "password" | "guest"; + username: string | null; +}; + +export function registerStorageRoutes(app: ModuleApp, ctx: ServerContext) { + configureStorage(ctx); + const S = { + ...crudDefs("/storage-instances", "存储实例", { + row: StorageInstance, + create: StorageInstanceCreate, + update: StorageInstanceUpdate, + }), + test: Api.storageInstances.test, + remove: Api.storageInstances.remove, + }; + const db = () => ctx.db; + + const getRow = async (id: string): Promise => { + const r = await db().selectFrom("storage_instance").selectAll().where("id", "=", id).executeTakeFirst(); + if (!r) throw new ApiException("not_found", "存储实例不存在"); + return r as unknown as Row; + }; + const normalize = (b: Partial) => { + const out: Record = { ...b }; + if (b.baseUrl !== undefined) out.baseUrl = b.baseUrl.trim().replace(/\/+$/, ""); + if (b.rootPath !== undefined) { + try { + out.rootPath = normAlistPath(b.rootPath); + } catch { + throw new ApiException("validation_failed", "根路径不合法"); + } + } + return out; + }; + const checkAuth = (authKind: string, username: string | null | undefined, hasCredential: boolean) => { + if (authKind === "token" && !hasCredential) throw new ApiException("validation_failed", "令牌认证需要填写令牌"); + if (authKind === "password" && (!username || !hasCredential)) + throw new ApiException("validation_failed", "账号密码认证需要填写用户名和密码"); + }; + const putSecret = async (oldRef: string | null, plaintext: string) => { + if (oldRef) { + await ctx.secrets.replace(oldRef, plaintext); + return oldRef; + } + return ctx.secrets.create({ purpose: "storage_credential", plaintext }); + }; + const view = (r: object) => toApi(r); + + endpoint(app, { ...S.list, tags }, async ({ query }) => { + const q0 = query as { cursor?: string; limit: number }; + const cur = decodeCursor(q0.cursor); + let q = db().selectFrom("storage_instance").selectAll().select(cursorCol("created_at")); + if (cur) q = q.where(afterCursor("created_at", cur)); + const rows = await q + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(q0.limit + 1) + .execute(); + const page = pageOf(rows as unknown as { cursor_us: number; id: string }[], q0.limit); + return { items: page.items.map(view), nextCursor: page.nextCursor } as never; + }); + endpoint(app, { ...S.get, tags }, async ({ params }) => view(await getRow(params.id)) as never); + + endpoint(app, { ...S.create, tags }, async ({ body, actor }) => { + const { credential: rawCredential, pathPassword: rawPw, ...rest } = body; + const credential = secretInput(rawCredential) ?? undefined; + const pathPassword = secretInput(rawPw) ?? undefined; + checkAuth(rest.authKind ?? "token", rest.username, !!credential); + const refs: string[] = []; + try { + const secretRef = + credential && (rest.authKind ?? "token") !== "guest" + ? await ctx.secrets.create({ purpose: "storage_credential", plaintext: credential }) + : null; + if (secretRef) refs.push(secretRef); + const pwRef = pathPassword + ? await ctx.secrets.create({ purpose: "storage_credential", plaintext: pathPassword }) + : null; + if (pwRef) refs.push(pwRef); + const row = await db() + .insertInto("storage_instance") + .values({ + ...(toDb(normalize(rest), JSON_COLS) as { name: string; base_url: string }), + secret_ref: secretRef, + path_password_ref: pwRef, + }) + .returningAll() + .executeTakeFirstOrThrow() + .catch((e: unknown) => pgConflict(e, "存储实例名称重复")); + await audit( + ctx, + actor, + "storage_instance.create", + { type: "storage_instance", id: row.id }, + { name: row.name, authKind: row.auth_kind }, + ); + return view(row) as never; + } catch (e) { + for (const r of refs) await ctx.secrets.remove(r).catch(() => {}); + throw e; + } + }); + + endpoint(app, { ...S.update, tags }, async ({ params, body, actor }) => { + const { credential: rawCredential, pathPassword: rawPw, ...rest } = body; + const credential = secretInput(rawCredential); + const pathPassword = secretInput(rawPw); + const cur = await getRow(params.id); + const authKind = rest.authKind ?? cur.auth_kind; + const username = rest.username !== undefined ? rest.username : cur.username; + const hasCred = credential === null ? false : credential ? true : !!cur.secret_ref; + checkAuth(authKind, username, hasCred); + const values = toDb(normalize(rest), JSON_COLS); + const drop: string[] = []; + if (authKind === "guest" || credential === null) { + if (cur.secret_ref) { + values.secret_ref = null; + drop.push(cur.secret_ref); + } + } else if (credential) values.secret_ref = await putSecret(cur.secret_ref, credential); + if (pathPassword === null) { + if (cur.path_password_ref) { + values.path_password_ref = null; + drop.push(cur.path_password_ref); + } + } else if (pathPassword) values.path_password_ref = await putSecret(cur.path_password_ref, pathPassword); + // 改了凭据也要让注册表重建客户端:updated_at 由触发器刷新;无字段变化时补一次 touch + const row = await db() + .updateTable("storage_instance") + .set({ ...(values as Record), updated_at: new Date() }) + .where("id", "=", params.id) + .returningAll() + .executeTakeFirst() + .catch((e: unknown) => pgConflict(e, "存储实例名称重复")); + if (!row) throw new ApiException("not_found", "存储实例不存在"); + for (const r of drop) await ctx.secrets.remove(r).catch(() => {}); + clearStorageCache(row.id); + await audit( + ctx, + actor, + "storage_instance.update", + { type: "storage_instance", id: row.id }, + { + fields: Object.keys(body).map((k) => + k === "credential" || k === "pathPassword" ? `${k}(${body[k] === null ? "cleared" : "set"})` : k, + ), + }, + ); + return view(row) as never; + }); + + endpoint(app, { ...S.remove, tags }, async ({ params, actor }) => { + const row = await getRow(params.id); + const prefix = `/@alist/${row.id}`; + const used = await db() + .selectFrom("library_root") + .select("name") + .where((eb) => eb.or([eb("local_path", "=", prefix), eb("local_path", "like", `${prefix}/%`)])) + .execute(); + if (used.length) + throw new ApiException( + "conflict", + `存储实例仍被媒体库目录使用:${used.map((u) => u.name).join("、")},先删除或改用其它存储`, + ); + await db().deleteFrom("storage_instance").where("id", "=", row.id).execute(); + for (const r of [row.secret_ref, row.path_password_ref]) if (r) await ctx.secrets.remove(r).catch(() => {}); + clearStorageCache(row.id); + await audit(ctx, actor, "storage_instance.delete", { type: "storage_instance", id: row.id }); + return { ok: true as const }; + }); + + endpoint(app, { ...S.test, tags }, async ({ params, actor }) => { + const row = await getRow(params.id); + const result = await testStorage(ctx, row); + await db() + .updateTable("storage_instance") + .set({ + health_status: result.healthStatus, + last_health_at: new Date(), + health_detail: JSON.stringify({ message: result.message, rootOk: result.rootOk, usage: result.usage }), + ...(result.version ? { version_info: JSON.stringify({ version: result.version, user: result.user }) } : {}), + }) + .where("id", "=", row.id) + .execute(); + clearStorageCache(row.id); + await audit(ctx, actor, "storage_instance.test", { type: "storage_instance", id: row.id }, { ok: result.ok }); + return result; + }); +} + +/** 测试连接(设置页「测试连接」与健康检查共用) */ +export async function testStorage( + ctx: Pick, + row: Row & { base_url?: unknown; root_path?: unknown; options?: unknown }, +): Promise { + let plain: { credential: string | null; pathPassword: string | null } = { credential: null, pathPassword: null }; + const fail = (healthStatus: StorageTestResult["healthStatus"], message: string): StorageTestResult => ({ + ok: false, + healthStatus, + version: null, + user: null, + rootOk: false, + usage: null, + message: redactAlistText(message, [plain.credential, plain.pathPassword]).slice(0, 500), + }); + try { + plain = await resolvePlain(ctx.secrets, row); + const client = buildClient(row as never, plain); + const version = await client.version(); + const user = row.auth_kind === "guest" ? "guest" : (await client.me()).username; + const root = await client.stat(String(row.root_path ?? "/")); + const usage = await client.usage(String(row.root_path ?? "/")); + if (!root?.isDir) return { ...fail("degraded", `根路径不存在或不是目录:${row.root_path}`), version, user, usage }; + return { ok: true, healthStatus: "healthy", version, user, rootOk: true, usage, message: null }; + } catch (e) { + if (e instanceof AlistError) { + const label = + e.kind === "auth" + ? row.auth_kind === "token" + ? "令牌失效或无权限" + : "登录失败(用户名或密码不对)" + : e.kind === "unavailable" || e.kind === "timeout" + ? "连不上" + : "接口出错"; + return fail("offline", `${label}:${e.message}`); + } + return fail("offline", String((e as Error)?.message ?? e)); + } +} diff --git a/apps/server/src/modules/system/health.ts b/apps/server/src/modules/system/health.ts index 207e5639..1cbc209e 100644 --- a/apps/server/src/modules/system/health.ts +++ b/apps/server/src/modules/system/health.ts @@ -10,6 +10,9 @@ import { SystemWorkerLane, } from "@umt/contracts"; import { type DB, type Kysely, type Selectable, sql } from "@umt/db"; +import { storageEntry } from "../../storage/registry.ts"; +import { isRemotePath, vcheckRoot } from "../../storage/vfs.ts"; +import { toVPath } from "../../storage/vpath.ts"; import { CURRENT_HOST_WINDOW_MS, HEARTBEAT_ROW_RETENTION_MS, HEARTBEAT_STALE_MS } from "./heartbeat.ts"; // GET /system/health 各段的取数(W9-I)。每段独立 try/catch:一段失败只把该段标 unknown,不让整页 500。 @@ -214,7 +217,14 @@ export async function loadPathTargets(db: Kysely) { .where("path_mapping.view_kind", "=", "downloader") .orderBy("path_mapping.local_prefix") .execute(); - return { roots, maps }; + const storages = await db + .selectFrom("storage_instance") + .select(["id", "name", "root_path"]) + .where("enabled", "=", true) + .orderBy("name") + .execute() + .catch(() => [] as { id: string; name: string; root_path: string }[]); + return { roots, maps, storages }; } export function diskLevel(ratio: number, t: HealthDeps["thresholds"]): HealthLevel { @@ -226,11 +236,14 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System const usage = d.diskUsage ?? diskUsage; const check = d.checkRoot ?? checkRoot; const ms = d.ioTimeoutMs ?? 3000; - const { roots, maps } = await loadPathTargets(d.db); + const { roots, maps, storages } = await loadPathTargets(d.db); const targets: PathTarget[] = [ { scope: "data_dir", id: null, name: "DATA_DIR", path: d.dataDir }, - ...roots.map((r) => ({ scope: "library_root" as const, id: r.id, name: r.name, path: r.local_path })), + // Alist 库根没有本机磁盘可统计(容量在文件管理按 mount_details 显示) + ...roots + .filter((r) => !isRemotePath(r.local_path)) + .map((r) => ({ scope: "library_root" as const, id: r.id, name: r.name, path: r.local_path })), ...maps.map((m) => ({ scope: "download_dir" as const, id: m.id, @@ -280,7 +293,12 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System ...roots.map(async (r): Promise => { const base = { kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path, sentinelChecked: true }; try { - const h = await withTimeout(check(r.local_path, r.sentinel_file ?? DEFAULT_ROOT_SENTINEL), ms); + const h = await withTimeout( + isRemotePath(r.local_path) + ? vcheckRoot(r.local_path) + : check(r.local_path, r.sentinel_file ?? DEFAULT_ROOT_SENTINEL), + isRemotePath(r.local_path) ? Math.max(ms, 15_000) : ms, + ); return h.kind === "online" ? { ...base, status: "online", reason: null } : { ...base, status: "offline", reason: h.reason }; @@ -316,6 +334,27 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System return { ...base, status: "offline", reason }; } }), + // Alist / OpenList 实例:连通 + 根路径可访问(令牌失效、连不上都在这里显示) + ...storages.map(async (s): Promise => { + const path = toVPath(s.id, s.root_path); + const base = { + kind: "storage_instance" as const, + id: s.id, + name: s.name, + path: s.root_path, + sentinelChecked: false, + }; + try { + await storageEntry(s.id); + const h = await withTimeout(vcheckRoot(path), Math.max(ms, 15_000)); + if (h.kind === "online") return { ...base, status: "online", reason: null }; + return { ...base, status: h.reason === "timeout" ? "timeout" : "offline", reason: h.reason }; + } catch (err) { + return err instanceof Timeout + ? { ...base, status: "timeout", reason: "timeout" } + : { ...base, status: "offline", reason: "error" }; + } + }), ]); return { disks, mounts }; } diff --git a/apps/server/src/preflight.ts b/apps/server/src/preflight.ts index a554bd06..281041a0 100644 --- a/apps/server/src/preflight.ts +++ b/apps/server/src/preflight.ts @@ -85,7 +85,10 @@ export async function loadMountedPaths(db: Kysely): Promise { .where("view_kind", "=", "downloader") .execute(); return [ - ...roots.map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })), + // Alist 库根(/@alist/…)不是本机挂载,由健康检查按实例连通性判断 + ...roots + .filter((r) => !r.local_path.startsWith("/@alist/")) + .map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })), ...maps.map((m) => ({ kind: "path_mapping" as const, id: m.id, name: m.remote_prefix, path: m.local_prefix })), ]; } diff --git a/apps/server/src/storage/probe.ts b/apps/server/src/storage/probe.ts new file mode 100644 index 00000000..4eb6df96 --- /dev/null +++ b/apps/server/src/storage/probe.ts @@ -0,0 +1,23 @@ +import { ffprobe, type ProbeResult, redactAlistText } from "@umt/adapters"; +import { vrawUrl } from "./vfs.ts"; +import { parseVPath } from "./vpath.ts"; + +// 远端文件的 ffprobe(docs/design/storage-alist.md §7):不下载,直接对签名直链探测,ffprobe 只读容器头与少量包(HTTP Range)。 +// 拿不到直链或探测失败返回 unknown(不阻塞,同本地 ffprobe 失败的口径)。直链进错误信息前脱敏。 + +export async function probeAny( + path: string, + local: (p: string) => Promise = (p) => ffprobe(p), +): Promise { + if (!parseVPath(path)) return local(path); + let url: string; + try { + url = await vrawUrl(path); + } catch (e) { + return { status: "unknown", reason: "probe_failed", message: redactAlistText(String((e as Error)?.message ?? e)) }; + } + const r = await ffprobe(url, { inputArgs: ["-rw_timeout", "30000000"], timeoutMs: 60_000 }); + if (r.status === "unknown" && r.message) + return { ...r, message: redactAlistText(r.message.split(url).join("<直链>")) }; + return r; +} diff --git a/apps/server/src/storage/registry.ts b/apps/server/src/storage/registry.ts new file mode 100644 index 00000000..92e6df15 --- /dev/null +++ b/apps/server/src/storage/registry.ts @@ -0,0 +1,170 @@ +import { type AlistAuth, AlistClient, AlistError } from "@umt/adapters"; +import { STORAGE_OPTION_DEFAULTS, type StorageOptions } from "@umt/contracts"; +import type { DB, Kysely, StorageInstanceRow } from "@umt/db"; +import type { ServerContext } from "../modules/types.ts"; + +// 存储实例注册表(docs/design/storage-alist.md §3):按 id 取实例 + 解密凭据 → AlistClient。 +// 进程级:模块装配时 configureStorage(ctx);实例行按 updated_at 缓存,配置一改客户端重建(令牌缓存随之作废,同 MP init_storage)。 + +export interface StorageEntry { + id: string; + name: string; + enabled: boolean; + /** 本系统可用的 Alist 根路径 */ + rootPath: string; + options: Required; + client: AlistClient; + /** 日志脱敏要遮的秘密值 */ + secrets: string[]; +} + +interface Source { + dataDir?: string | undefined; + db: Kysely; + secrets: ServerContext["secrets"]; + logger?: ServerContext["logger"] | undefined; +} + +let source: Source | null = null; +const cache = new Map(); +const overrides = new Map(); +const RECHECK_MS = 5_000; + +/** 模块装配时调用(幂等;最后一次为准) */ +export function configureStorage( + ctx: Pick & { + logger?: ServerContext["logger"]; + config?: Partial>; + }, +) { + if (source?.db !== ctx.db) cache.clear(); + source = { db: ctx.db, secrets: ctx.secrets, logger: ctx.logger, dataDir: ctx.config?.DATA_DIR }; +} + +/** 数据目录(跨存储中转、上传暂存放在 /cache/storage-relay) */ +export const storageDataDir = (): string | undefined => source?.dataDir; + +/** 测试用:直接给某个存储 id 指定客户端(mock Alist) */ +export function overrideStorage( + id: string, + e: { client: AlistClient; name?: string; rootPath?: string; options?: StorageOptions } | null, +): void { + if (!e) { + overrides.delete(id); + return; + } + overrides.set(id, { + id, + name: e.name ?? "Alist", + enabled: true, + rootPath: e.rootPath ?? "/", + options: { ...STORAGE_OPTION_DEFAULTS, ...(e.options ?? {}) }, + client: e.client, + secrets: [], + }); +} + +export function clearStorageCache(id?: string): void { + if (id) cache.delete(id); + else cache.clear(); +} + +export const storageOptions = (o: unknown): Required => ({ + ...STORAGE_OPTION_DEFAULTS, + ...((o && typeof o === "object" ? o : {}) as StorageOptions), +}); + +/** 由实例行 + 明文凭据建客户端(测试连接、注册表共用) */ +export function buildClient( + row: Pick, + plain: { credential: string | null; pathPassword: string | null }, + logger?: ServerContext["logger"], +): AlistClient { + const o = storageOptions(row.options); + let auth: AlistAuth; + if (row.auth_kind === "token") { + if (!plain.credential) throw new AlistError("auth", "没有配置令牌"); + auth = { kind: "token", token: plain.credential }; + } else if (row.auth_kind === "password") { + if (!row.username || !plain.credential) throw new AlistError("auth", "账号密码认证需要用户名和密码"); + auth = { kind: "password", username: row.username, password: plain.credential }; + } else auth = { kind: "guest" }; + return new AlistClient({ + baseUrl: row.base_url, + auth, + pathPassword: plain.pathPassword, + qps: o.qps, + concurrency: o.concurrency, + timeoutMs: o.timeoutSec * 1000, + idleTimeoutMs: o.idleTimeoutSec * 1000, + taskTimeoutMs: o.taskTimeoutMin * 60_000, + retries: o.retries, + uploadAsTask: o.uploadAsTask, + ...(logger + ? { + logger: { + debug: (obj, msg) => logger.debug(obj, msg), + warn: (obj, msg) => logger.warn(obj, msg), + }, + } + : {}), + }); +} + +export async function resolvePlain( + secrets: Source["secrets"], + row: Pick, +) { + return { + credential: row.secret_ref ? await secrets.resolve(row.secret_ref) : null, + pathPassword: row.path_password_ref ? await secrets.resolve(row.path_password_ref) : null, + }; +} + +/** 取存储实例;不存在 / 停用 / 未装配抛 AlistError(unavailable,按「存储离线」处理,不当成文件不存在) */ +export async function storageEntry(id: string, opts: { allowDisabled?: boolean } = {}): Promise { + const o = overrides.get(id); + if (o) return o; + if (!source) throw new AlistError("unavailable", "存储注册表未初始化"); + const hit = cache.get(id); + if (hit && Date.now() - hit.checkedAt < RECHECK_MS) return check(hit.entry, opts); + const row = await source.db.selectFrom("storage_instance").selectAll().where("id", "=", id).executeTakeFirst(); + if (!row) throw new AlistError("unavailable", `存储实例不存在:${id}`); + const stamp = new Date(row.updated_at).toISOString(); + if (hit && hit.stamp === stamp) { + hit.checkedAt = Date.now(); + return check(hit.entry, opts); + } + const plain = await resolvePlain(source.secrets, row); + const entry: StorageEntry = { + id: row.id, + name: row.name, + enabled: row.enabled, + rootPath: row.root_path, + options: storageOptions(row.options), + client: buildClient(row, plain, source.logger), + secrets: [plain.credential, plain.pathPassword].filter((s): s is string => !!s), + }; + cache.set(id, { stamp, entry, checkedAt: Date.now() }); + return check(entry, opts); +} + +function check(e: StorageEntry, opts: { allowDisabled?: boolean }): StorageEntry { + if (!e.enabled && !opts.allowDisabled) throw new AlistError("unavailable", `存储实例已停用:${e.name}`); + return e; +} + +/** 启用的实例(文件管理根、健康检查) */ +export async function listStorages(db?: Kysely): Promise { + const d = db ?? source?.db; + if (!d) return []; + return d.selectFrom("storage_instance").selectAll().orderBy("name").execute(); +} + +/** 存储名表(展示「存储名:/路径」) */ +export async function storageNames(db?: Kysely): Promise> { + const m = new Map(); + for (const [id, e] of overrides) m.set(id, e.name); + for (const r of await listStorages(db)) m.set(r.id, r.name); + return m; +} diff --git a/apps/server/src/storage/transfer.ts b/apps/server/src/storage/transfer.ts new file mode 100644 index 00000000..021e29ab --- /dev/null +++ b/apps/server/src/storage/transfer.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rename, rm, stat, unlink } from "node:fs/promises"; +import { basename, dirname, join, posix } from "node:path"; +import { AlistError, type ProgressCallback } from "@umt/adapters"; +import { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts"; +import { parseVPath, sameStorage } from "./vpath.ts"; + +// 整理矩阵中涉及 Alist 的方向(docs/design/storage-alist.md §5): +// 本地→Alist 上传、Alist→本地 下载、同实例 Alist→Alist API copy / move、跨实例 下载→上传。 +// 成功判定与 MP 一致(用户决定):接口成功 + 目标存在,不比对大小 / 哈希;移动在判定通过后删源,删源失败 = attention。 +// 本地 ↔ 本地不经这里(仍走 fsproxy 与现有核验)。 + +export interface RemoteTransferInput { + kind: "copy" | "move"; + src: string; + dst: string; + /** 目标已存在:conflict 失败;replace 先把旧文件移到 replaceTrashDir(同存储),成功后旧文件留在那里,失败移回 */ + onExisting: "conflict" | "replace"; + replaceTrashDir?: string; + /** 本次是重试(上次可能已完成外部动作) */ + resumed?: boolean; + signal?: AbortSignal; + onProgress?: ProgressCallback; +} + +export interface RemoteTransferResult { + status: "succeeded" | "failed" | "attention"; + bytes?: number; + /** 顺带得到的本地文件 sha256(上传的源 / 下载的结果),只是事实记录 */ + checksum?: string; + resumed?: boolean; + error?: { code: string; message: string }; + replaced?: { disposition: "trash"; path: string; size: number; mtimeMs: number }; + steps?: { step: string; status: string; at: string; detail?: string; error?: { code: string; message: string } }[]; + via: "upload" | "download" | "alist_copy" | "alist_move" | "relay"; +} + +/** Alist / 本地错误 → 整理账本错误码(可重试的 IO/TIMEOUT 走任务重试) */ +export function transferErrorCode(e: unknown, role: "源" | "目标" | null = null): { code: string; message: string } { + if (e instanceof AlistError) { + const code = + e.kind === "not_found" + ? role === "源" + ? "SOURCE_MISSING" + : "IO" + : e.kind === "conflict" + ? "EEXIST" + : e.kind === "auth" + ? "EACCES" + : e.kind === "timeout" || e.kind === "rate_limited" + ? "TIMEOUT" + : e.kind === "aborted" + ? "ABORTED" + : "IO"; + return { code, message: `Alist:${e.message}` }; + } + const c = (e as { code?: string } | null)?.code; + const message = (e as Error)?.message ?? String(e); + if (c === "ENOENT") return { code: role === "源" ? "SOURCE_MISSING" : "IO", message }; + if (c === "EEXIST") return { code: "EEXIST", message }; + if (c === "EACCES" || c === "EPERM") return { code: "EACCES", message }; + if (c === "ENOSPC") return { code: "ENOSPC", message }; + if (c === "ETIMEDOUT") return { code: "TIMEOUT", message }; + if (c === "ABORT_ERR") return { code: "ABORTED", message }; + return { code: "IO", message }; +} + +const now = () => new Date().toISOString(); + +async function localExists(p: string): Promise<{ size: number; mtimeMs: number } | null> { + try { + const s = await stat(p); + return { size: s.size, mtimeMs: s.mtimeMs }; + } catch (e) { + if ((e as { code?: string }).code === "ENOENT") return null; + throw e; + } +} + +/** 任一端是远端路径? */ +export const involvesRemote = (src: string, dst: string) => !!parseVPath(src) || !!parseVPath(dst); + +/** 执行一次涉及远端的复制 / 移动 */ +export async function remoteTransfer(x: RemoteTransferInput): Promise { + const rs = parseVPath(x.src); + const rd = parseVPath(x.dst); + if (rs) invalidateRemote(x.src); + if (rd) invalidateRemote(x.dst); + const via: RemoteTransferResult["via"] = + rs && rd + ? sameStorage(x.src, x.dst) + ? x.kind === "copy" + ? "alist_copy" + : "alist_move" + : "relay" + : rd + ? "upload" + : "download"; + const fail = (e: unknown, role: "源" | "目标" | null = null): RemoteTransferResult => ({ + status: "failed", + error: transferErrorCode(e, role), + via, + }); + + // ── 源 / 目标状态 ── + let srcSize = 0; + let srcExists = true; + try { + if (rs) { + const { client } = await remote(x.src); + const o = await client.stat(rs.remote); + srcExists = !!o; + srcSize = o?.size ?? 0; + if (o?.isDir) return fail(new AlistError("error", `源是目录:${x.src}`)); + } else { + const s = await localExists(x.src); + srcExists = !!s; + srcSize = s?.size ?? 0; + } + } catch (e) { + return fail(e, "源"); + } + let dstFacts: { size: number; mtimeMs: number } | null; + try { + if (rd) { + const { client } = await remote(x.dst); + const o = await client.stat(rd.remote); + dstFacts = o ? { size: o.size, mtimeMs: o.modified } : null; + } else dstFacts = await localExists(x.dst); + } catch (e) { + return fail(e, "目标"); + } + // 对账:上次尝试已完成外部动作但没留下结果(目标都是「临时名 → 改名」落地的,目标在 = 上次已写完) + if (dstFacts && x.resumed && x.onExisting !== "replace") { + if (x.kind === "move" && srcExists) { + const del = await deleteSource(x.src).then( + () => null, + (e) => transferErrorCode(e), + ); + if (del) + return { + status: "attention", + bytes: dstFacts.size, + resumed: true, + via, + steps: [{ step: "delete_source", status: "failed", at: now(), error: del }], + }; + } + return { status: "succeeded", bytes: dstFacts.size, resumed: true, via }; + } + if (!srcExists) { + if (x.kind === "move" && dstFacts) return { status: "succeeded", bytes: dstFacts.size, resumed: true, via }; + return { status: "failed", error: { code: "SOURCE_MISSING", message: `源文件不存在:${x.src}` }, via }; + } + if (dstFacts && x.onExisting !== "replace") + return { status: "failed", error: { code: "EEXIST", message: `目标已存在:${x.dst}` }, via }; + + // ── 替换:旧目标先移走 ── + let parked: { path: string; size: number; mtimeMs: number } | null = null; + if (dstFacts && x.onExisting === "replace") { + const trashDir = x.replaceTrashDir ?? join(dirname(x.dst), ".umt-trash"); + const to = join(trashDir, `replaced-${Date.now()}-${randomUUID().slice(0, 6)}`, basename(x.dst)); + try { + await parkOld(x.dst, to); + parked = { path: to, size: dstFacts.size, mtimeMs: dstFacts.mtimeMs }; + } catch (e) { + return fail(e, "目标"); + } + } + const restoreParked = async () => { + if (parked) await parkOld(parked.path, x.dst).catch(() => undefined); + }; + + // ── 传输 ── + let bytes = srcSize; + let checksum: string | undefined; + try { + if (rd && !rs) { + const { client } = await remote(x.dst); + const r = await client.upload(x.src, rd.remote, { + ...(x.signal ? { signal: x.signal } : {}), + ...(x.onProgress ? { onProgress: x.onProgress } : {}), + }); + bytes = r.bytes; + checksum = `sha256:${r.hashes.sha256}`; + } else if (rs && !rd) { + await mkdir(dirname(x.dst), { recursive: true }); + const { client } = await remote(x.src); + const r = await client.download(rs.remote, x.dst, { + ...(x.signal ? { signal: x.signal } : {}), + ...(x.onProgress ? { onProgress: x.onProgress } : {}), + }); + bytes = r.bytes; + checksum = `sha256:${r.sha256}`; + } else if (rs && rd && sameStorage(x.src, x.dst)) { + const { client } = await remote(x.src); + const opts = x.signal ? { signal: x.signal } : {}; + const o = + x.kind === "copy" + ? await client.copyFile(rs.remote, rd.remote, opts) + : await client.moveFile(rs.remote, rd.remote, opts); + bytes = o.size || srcSize; + } else if (rs && rd) { + // 跨实例:下载到本机中转 → 上传(MP 不支持,我们补上) + const tmpDir = join(relayDir(), randomUUID()); + await mkdir(tmpDir, { recursive: true }); + const tmp = join(tmpDir, posix.basename(rs.remote)); + try { + const a = await remote(x.src); + await a.client.download(rs.remote, tmp, { + ...(x.signal ? { signal: x.signal } : {}), + ...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(d / 2, t) } : {}), + }); + const b = await remote(x.dst); + const r = await b.client.upload(tmp, rd.remote, { + ...(x.signal ? { signal: x.signal } : {}), + ...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(t / 2 + d / 2, t) } : {}), + }); + bytes = r.bytes; + checksum = `sha256:${r.hashes.sha256}`; + } finally { + await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } + } + } catch (e) { + await restoreParked(); + return fail(e); + } + const out: RemoteTransferResult = { + status: "succeeded", + bytes, + ...(checksum ? { checksum } : {}), + via, + ...(parked ? { replaced: { disposition: "trash" as const, ...parked } } : {}), + }; + // 同实例 API move 自己删了源;其它移动在成功后删源 + if (x.kind === "move" && via !== "alist_move") { + const del = await deleteSource(x.src).then( + () => null, + (e) => transferErrorCode(e), + ); + if (del) + return { + ...out, + status: "attention", + steps: [{ step: "delete_source", status: "failed", at: now(), error: del }], + }; + } + return out; +} + +async function deleteSource(src: string): Promise { + const v = parseVPath(src); + if (!v) { + await unlink(src); + return; + } + const { client } = await remote(src); + try { + await client.removePath(v.remote); + } catch (e) { + throw remoteError(e, src); + } +} + +/** 同存储内把 from 移到 to(替换时暂放旧文件 / 失败移回) */ +async function parkOld(from: string, to: string): Promise { + const a = parseVPath(from); + const b = parseVPath(to); + if (!a && !b) { + await mkdir(dirname(to), { recursive: true }); + await rename(from, to); + return; + } + if (!a || !b || a.storageId !== b.storageId) throw new AlistError("error", "替换暂存必须与目标在同一存储"); + const { client } = await remote(from); + await client.moveFile(a.remote, b.remote); +} diff --git a/apps/server/src/storage/vfs.ts b/apps/server/src/storage/vfs.ts new file mode 100644 index 00000000..2221c5ac --- /dev/null +++ b/apps/server/src/storage/vfs.ts @@ -0,0 +1,356 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, posix } from "node:path"; +import { + type AlistClient, + AlistError, + checkRoot, + DEFAULT_ROOT_SENTINEL, + type FileFacts, + type RootHealth, + type StatResult, + statSafe, +} from "@umt/adapters"; +import { storageDataDir, storageEntry } from "./registry.ts"; +import { parseVPath, toVPath, type VPath } from "./vpath.ts"; + +// 本地 / Alist 统一的文件操作(docs/design/storage-alist.md §3)。本地分支原样调用 node:fs / adapters, +// 远端分支经注册表取 AlistClient。远端错误映射成 errno 风格的 code:不存在 ENOENT、冲突 EEXIST、鉴权 EACCES, +// 连接 / 服务端失败 EHOSTDOWN / ETIMEDOUT(文件管理按「根不可用」503 处理,整理按可重试 IO 处理,绝不当成不存在)。 + +export { isRemotePath, parseVPath, toVPath } from "./vpath.ts"; + +export interface VStats { + size: number; + mtimeMs: number; + ino: number; + dev: number; + nlink: number; + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +} + +/** Alist 错误 → 带 errno code 的 Error */ +export function remoteError(e: unknown, path?: string): Error & { code: string } { + if (e instanceof AlistError) { + const code = + e.kind === "not_found" + ? "ENOENT" + : e.kind === "conflict" + ? "EEXIST" + : e.kind === "auth" + ? "EACCES" + : e.kind === "timeout" || e.kind === "rate_limited" + ? "ETIMEDOUT" + : e.kind === "aborted" + ? "ABORT_ERR" + : "EHOSTDOWN"; + return Object.assign(new Error(`Alist:${e.message}${path ? `(${path})` : ""}`), { code, alist: e.kind }); + } + if (e && typeof e === "object" && "code" in e) return e as Error & { code: string }; + return Object.assign(new Error(String((e as Error)?.message ?? e)), { code: "EIO" }); +} + +/** 远端对象的伪设备号:同一存储实例相同,与本机设备号不冲突 */ +export function remoteDev(storageId: string): number { + return 2 ** 40 + Number.parseInt(createHash("sha1").update(storageId).digest("hex").slice(0, 8), 16); +} + +export async function remote(p: string): Promise<{ v: VPath; client: AlistClient }> { + const v = parseVPath(p); + if (!v) throw Object.assign(new Error(`不是远端路径:${p}`), { code: "EINVAL" }); + try { + const e = await storageEntry(v.storageId); + return { v, client: e.client }; + } catch (err) { + throw remoteError(err, p); + } +} + +const statsOf = (storageId: string, o: { isDir: boolean; size: number; modified: number }): VStats => ({ + size: o.isDir ? 0 : o.size, + mtimeMs: o.modified, + ino: 0, + dev: remoteDev(storageId), + nlink: 1, + isFile: () => !o.isDir, + isDirectory: () => o.isDir, + isSymbolicLink: () => false, +}); + +export const factsOf = (s: VStats): FileFacts => ({ + size: Number(s.size), + mtimeMs: Number(s.mtimeMs), + ino: Number(s.ino), + dev: Number(s.dev), + nlink: Number(s.nlink), + isFile: s.isFile(), + isDirectory: s.isDirectory(), + isSymbolicLink: s.isSymbolicLink(), +}); + +// 远端 stat 短缓存:列目录拿到的子项 stat 在几秒内复用,避免遍历时逐个 get(§9-8 限制请求量);写操作按目录失效 +const STAT_TTL_MS = 3000; +const statCache = new Map(); +function cacheStat(p: string, stats: VStats) { + statCache.set(p, { at: Date.now(), stats }); + if (statCache.size > 20_000) statCache.delete(statCache.keys().next().value!); +} +/** 写后失效:该路径、其子树与父目录 */ +export function invalidateRemote(p: string): void { + const parent = posix.dirname(p); + for (const k of statCache.keys()) if (k === p || k.startsWith(`${p}/`) || k === parent) statCache.delete(k); +} + +export async function vlstat(p: string, opts: { refresh?: boolean } = {}): Promise { + if (!parseVPath(p)) return (await lstat(p)) as unknown as VStats; + if (!opts.refresh) { + const hit = statCache.get(p); + if (hit && Date.now() - hit.at < STAT_TTL_MS) return hit.stats; + } + const { v, client } = await remote(p); + try { + const st = statsOf(v.storageId, await client.get(v.remote, opts)); + cacheStat(p, st); + return st; + } catch (e) { + throw remoteError(e, p); + } +} + +/** 远端没有符号链接:realpath = 规范化后的路径(存在才返回) */ +export async function vrealpath(p: string): Promise { + const v = parseVPath(p); + if (!v) return realpath(p); + await vlstat(p); + return toVPath(v.storageId, v.remote); +} + +export interface VDirent { + name: string; + stats: VStats; +} + +/** 列目录(带每项的 stat;远端一次 list 拿全) */ +export async function vreaddirStats(p: string, opts: { refresh?: boolean } = {}): Promise { + const v = parseVPath(p); + if (!v) { + const names = await readdir(p); + const out: VDirent[] = []; + for (const name of names) { + const st = await lstat(join(p, name)).catch(() => null); + if (st) out.push({ name, stats: st as unknown as VStats }); + } + return out; + } + const { client } = await remote(p); + try { + return (await client.list(v.remote, opts)).map((o) => { + const stats = statsOf(v.storageId, o); + cacheStat(join(p, o.name), stats); + return { name: o.name, stats }; + }); + } catch (e) { + throw remoteError(e, p); + } +} + +export interface VDirentLite { + name: string; + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; +} + +/** readdir(withFileTypes) 的统一版本;本地照旧 */ +export async function vreaddirents(p: string): Promise { + if (!parseVPath(p)) return readdir(p, { withFileTypes: true }); + return (await vreaddirStats(p)).map((d) => ({ + name: d.name, + isDirectory: () => d.stats.isDirectory(), + isFile: () => d.stats.isFile(), + isSymbolicLink: () => false, + })); +} + +export async function vreaddir(p: string): Promise { + if (!parseVPath(p)) return readdir(p); + return (await vreaddirStats(p)).map((d) => d.name).sort(); +} + +/** 同 adapters statSafe 的口径;远端失败(连不上 / 超时 / 鉴权)返回 error,只有明确不存在才是 missing */ +export async function vstatSafe(p: string, opts: { root?: string; sentinel?: string } = {}): Promise { + if (!parseVPath(p)) return statSafe(p, opts); + if (opts.root) { + const h = await vcheckRoot(opts.root, opts.sentinel); + if (h.kind === "offline") return { kind: "root_offline", root: opts.root, reason: h.reason }; + } + try { + return { kind: "ok", facts: factsOf(await vlstat(p)) }; + } catch (e) { + const err = remoteError(e, p); + if (err.code === "ENOENT") return { kind: "missing" }; + if (err.code === "EACCES") return { kind: "permission_denied" }; + return { kind: "error", code: err.code === "ETIMEDOUT" ? "TIMEOUT" : "IO", message: err.message }; + } +} + +/** 根在线:本地按哨兵;远端 = 根路径存在且是目录(Alist 不用哨兵,同 MP) */ +export async function vcheckRoot(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise { + const v = parseVPath(root); + if (!v) return checkRoot(root, sentinel); + try { + const st = await vlstat(root); + if (!st.isDirectory()) return { kind: "offline", reason: "root_missing" }; + return { kind: "online", dev: remoteDev(v.storageId) }; + } catch (e) { + const err = remoteError(e, root); + if (err.code === "ENOENT") return { kind: "offline", reason: "root_missing" }; + if (err.code === "EACCES") return { kind: "offline", reason: "permission_denied", code: "EACCES" }; + if (err.code === "ETIMEDOUT") return { kind: "offline", reason: "timeout" }; + return { kind: "offline", reason: "error", code: err.code }; + } +} + +export async function vmkdirp(p: string): Promise { + const v = parseVPath(p); + if (!v) { + await mkdir(p, { recursive: true }); + return; + } + const { client } = await remote(p); + invalidateRemote(p); + await client.mkdir(v.remote).catch((e) => { + throw remoteError(e, p); + }); +} + +export async function vexists(p: string): Promise { + try { + await vlstat(p); + return true; + } catch (e) { + if ((e as { code?: string }).code === "ENOENT") return false; + throw e; + } +} + +export async function vreadFile(p: string, maxBytes = 4 * 1024 * 1024): Promise { + const v = parseVPath(p); + if (!v) return new Uint8Array(await readFile(p)); + const { client } = await remote(p); + try { + return await client.readFile(v.remote, maxBytes); + } catch (e) { + throw remoteError(e, p); + } +} + +/** 本机临时目录(远端写入的本地中转) */ +export function relayDir(): string { + return join(storageDataDir() ?? Deno.env.get("DATA_DIR") ?? tmpdir(), "cache", "storage-relay"); +} + +/** + * 原子写:本地同目录临时文件 → rename;远端本地生成后上传(同 MP 刮削 `_save_file`): + * 上传到旁边的临时名,成功后删旧文件(若有)再改名。 + */ +export async function vwriteFileAtomic(p: string, content: Uint8Array | string): Promise { + const v = parseVPath(p); + if (!v) { + await mkdir(dirname(p), { recursive: true }); + const tmp = `${p}.${randomUUID().slice(0, 8)}.tmp`; + try { + await writeFile(tmp, content); + await rename(tmp, p); + } catch (e) { + await rm(tmp, { force: true }).catch(() => {}); + throw e; + } + return; + } + const { client } = await remote(p); + invalidateRemote(p); + const dir = relayDir(); + await mkdir(dir, { recursive: true }); + const local = join(dir, `${randomUUID()}-${basename(p)}`); + const stage = `${v.remote}.umt-new`; + try { + await writeFile(local, content); + if (await client.stat(stage)) await client.removePath(stage); + await client.upload(local, stage); + if (await client.stat(v.remote)) await client.removePath(v.remote); + await client.rename(stage, posix.basename(v.remote)); + } catch (e) { + await client.removePath(stage).catch(() => undefined); + throw remoteError(e, p); + } finally { + await rm(local, { force: true }).catch(() => {}); + } +} + +/** 删除文件或目录(远端 remove;本地 rm -r) */ +export async function vremove(p: string): Promise { + const v = parseVPath(p); + if (!v) { + await rm(p, { recursive: true, force: true }); + return; + } + const { client } = await remote(p); + invalidateRemote(p); + await client.removePath(v.remote).catch((e) => { + throw remoteError(e, p); + }); +} + +/** 同存储内移动 / 改名(远端用 API move,目标已存在 EEXIST;本地 rename 不覆盖由调用方保证) */ +export async function vmove(src: string, dst: string): Promise { + const a = parseVPath(src); + const b = parseVPath(dst); + if (!a && !b) { + await mkdir(dirname(dst), { recursive: true }); + await rename(src, dst); + return; + } + if (!a || !b || a.storageId !== b.storageId) + throw Object.assign(new Error(`跨存储不能直接移动:${src} → ${dst}`), { code: "EXDEV" }); + const { client } = await remote(src); + invalidateRemote(src); + invalidateRemote(dst); + await client.moveFile(a.remote, b.remote).catch((e) => { + throw remoteError(e, src); + }); +} + +/** 远端直链(ffprobe 用;日志前要脱敏) */ +export async function vrawUrl(p: string): Promise { + const { v, client } = await remote(p); + return client.rawUrl(v.remote).catch((e) => { + throw remoteError(e, p); + }); +} + +/** 同目录不覆盖改名(远端 Alist rename 本身拒绝同名;本地走 adapters moveSafe) */ +export async function vrenameNoClobber(from: string, to: string): Promise<{ status: "moved" | "exists" | "exdev" }> { + const a = parseVPath(from); + if (!a) { + const { moveSafe } = await import("@umt/adapters"); + return moveSafe(from, to); + } + const b = parseVPath(to); + if (!b || b.storageId !== a.storageId) return { status: "exdev" }; + const { client } = await remote(from); + invalidateRemote(from); + invalidateRemote(to); + try { + if (await client.stat(b.remote)) return { status: "exists" }; + if (posix.dirname(a.remote) === posix.dirname(b.remote)) await client.rename(a.remote, posix.basename(b.remote)); + else await client.moveFile(a.remote, b.remote); + return { status: "moved" }; + } catch (e) { + if (e instanceof AlistError && e.kind === "conflict") return { status: "exists" }; + throw remoteError(e, from); + } +} diff --git a/apps/server/src/storage/vpath.ts b/apps/server/src/storage/vpath.ts new file mode 100644 index 00000000..c4a151ec --- /dev/null +++ b/apps/server/src/storage/vpath.ts @@ -0,0 +1,45 @@ +import { posix } from "node:path"; + +// 远端路径的虚拟前缀(docs/design/storage-alist.md §0、§3):/@alist/<存储 id>/。 +// 它是合法的 POSIX 绝对路径,join / dirname / relative 照常可用;本机没有 /@alist, +// 不认识前缀的旧代码对它做本地 IO 只会得到 ENOENT(失败安全)。 + +export const VROOT = "/@alist"; +const RE = /^\/@alist\/([0-9a-f-]{36})(\/.*)?$/i; + +export interface VPath { + storageId: string; + /** Alist 内绝对路径(以 / 开头,无尾斜杠) */ + remote: string; +} + +export const isRemotePath = (p: string | null | undefined): boolean => + !!p && (p === VROOT || p.startsWith(`${VROOT}/`)); + +export function parseVPath(p: string): VPath | null { + if (!isRemotePath(p)) return null; + const m = RE.exec(p.replace(/\/+$/, "")); + if (!m) return null; + const remote = posix.normalize(m[2] ?? "/"); + if (remote.split("/").includes("..")) return null; + return { storageId: m[1]!.toLowerCase(), remote: remote === "/" ? "/" : remote.replace(/\/+$/, "") }; +} + +export function toVPath(storageId: string, remote: string): string { + const r = posix.normalize(`/${remote}`).replace(/\/+$/, ""); + return `${VROOT}/${storageId}${r === "" || r === "/" ? "" : r}`; +} + +/** 同一存储实例? */ +export function sameStorage(a: string, b: string): boolean { + const pa = parseVPath(a); + const pb = parseVPath(b); + return !!pa && !!pb && pa.storageId === pb.storageId; +} + +/** 界面 / 日志展示:「存储名:/路径」;本地原样 */ +export function displayPath(p: string, names: ReadonlyMap = new Map()): string { + const v = parseVPath(p); + if (!v) return p; + return `${names.get(v.storageId) ?? "Alist"}:${v.remote}`; +} diff --git a/apps/server/test/auth-settings/storage-instances.test.ts b/apps/server/test/auth-settings/storage-instances.test.ts new file mode 100644 index 00000000..3e310d9c --- /dev/null +++ b/apps/server/test/auth-settings/storage-instances.test.ts @@ -0,0 +1,176 @@ +import { createMockAlist, serveMockAlist } from "@umt/adapters/alist-mock"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildHealth } from "../../src/modules/system/health.ts"; +import { clearStorageCache, storageEntry } from "../../src/storage/registry.ts"; +import { DEV_KEY, json } from "../helpers.ts"; +import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; + +// 存储实例(Alist / OpenList)设置接口:只写凭据、测试连接(版本 / 用户 / 根路径 / 容量)、 +// 令牌失效 → offline、被库根引用不能删、健康检查里的存储实例(docs/design/storage-alist.md §3、§4)。 + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("存储实例(真实 PG + 模拟 Alist HTTP)", () => { + let h: Harness; + let admin: ReturnType; + const mock = createMockAlist({ + token: "perm-token-xyz", + username: "alice", + password: "pw-alice", + mountDetails: { "/s1": { total_space: 1000, free_space: 250, used_space: 750 } }, + }); + mock.mkdirp("/s1/media"); + let srv: { base: string; close: () => Promise }; + + beforeAll(async () => { + srv = serveMockAlist(mock); + h = await createHarness({ engine: false }); + admin = bearer(h.app, DEV_KEY); + }, 60_000); + afterAll(async () => { + await h?.close(); + await srv?.close(); + }); + + it("新建(令牌):凭据只写不读;测试连接拿到版本、用户、根路径、容量", async () => { + const c = await admin("POST", "/storage-instances", { + name: "openlist-a", + baseUrl: `${srv.base}/`, + rootPath: "/s1/media/", + authKind: "token", + credential: "perm-token-xyz", + pathPassword: "folder-pw", + }); + expect(c.status).toBe(200); + const row = (await json(c)).data; + expect(row.baseUrl).toBe(srv.base); + expect(row.rootPath).toBe("/s1/media"); + expect(row.secretRef).toBeTruthy(); + expect(row.pathPasswordRef).toBeTruthy(); + expect(JSON.stringify(row)).not.toContain("perm-token-xyz"); + expect(JSON.stringify(row)).not.toContain("folder-pw"); + expect(await h.ctx.secrets.resolve(row.secretRef)).toBe("perm-token-xyz"); + + const t = (await json(await admin("POST", `/storage-instances/${row.id}/test`))).data; + expect(t).toMatchObject({ ok: true, healthStatus: "healthy", user: "alice", rootOk: true }); + expect(t.version).toContain("v4"); + expect(t.usage).toEqual({ total: 1000, free: 250, used: 750 }); + const after = (await json(await admin("GET", `/storage-instances/${row.id}`))).data; + expect(after.healthStatus).toBe("healthy"); + }); + + it("账号密码:缺密码 422;改成错密码后测试 = offline 且信息里没有密码", async () => { + const bad = await admin("POST", "/storage-instances", { + name: "pw-missing", + baseUrl: srv.base, + authKind: "password", + username: "alice", + }); + expect(bad.status).toBe(422); + const c = ( + await json( + await admin("POST", "/storage-instances", { + name: "openlist-pw", + baseUrl: srv.base, + authKind: "password", + username: "alice", + credential: "pw-alice", + }), + ) + ).data; + expect((await json(await admin("POST", `/storage-instances/${c.id}/test`))).data.ok).toBe(true); + await admin("PATCH", `/storage-instances/${c.id}`, { credential: "wrong-secret-pw" }); + const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data; + expect(t).toMatchObject({ ok: false, healthStatus: "offline" }); + expect(t.message).toContain("登录失败"); + expect(t.message).not.toContain("wrong-secret-pw"); + }); + + it("永久令牌失效:测试与健康检查都报不可用", async () => { + const c = ( + await json( + await admin("POST", "/storage-instances", { + name: "openlist-badtoken", + baseUrl: srv.base, + rootPath: "/s1", + credential: "revoked-token", + }), + ) + ).data; + const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data; + expect(t.ok).toBe(false); + expect(t.message).toContain("令牌失效"); + clearStorageCache(); + const health = await buildHealth({ + db: h.ctx.db, + engineSchema: "openworkflow", + dataDir: "/tmp", + self: { version: "t", startedAt: new Date(), sseConnections: 0 }, + thresholds: { warnPercent: 85, criticalPercent: 95 }, + }); + const m = health.mounts.find((x) => x.kind === "storage_instance" && x.id === c.id); + expect(m).toMatchObject({ status: "offline", reason: "permission_denied" }); + const ok = health.mounts.find((x) => x.kind === "storage_instance" && x.name === "openlist-a"); + expect(ok?.status).toBe("online"); + }); + + it("改凭据后注册表重建客户端;清除路径密码", async () => { + const row = (await json(await admin("GET", "/storage-instances?limit=50"))).data.items.find( + (x: { name: string }) => x.name === "openlist-a", + ); + const e1 = await storageEntry(row.id); + expect(e1.secrets).toContain("folder-pw"); + const r = (await json(await admin("PATCH", `/storage-instances/${row.id}`, { pathPassword: null }))).data; + expect(r.pathPasswordRef).toBeNull(); + const e2 = await storageEntry(row.id); + expect(e2.client).not.toBe(e1.client); + expect(e2.secrets).not.toContain("folder-pw"); + }); + + it("被媒体库目录引用时不能删;解除后可删,凭据一并删除", async () => { + const c = ( + await json( + await admin("POST", "/storage-instances", { + name: "to-delete", + baseUrl: srv.base, + rootPath: "/s1", + credential: "perm-token-xyz", + }), + ) + ).data; + const lib = await admin("POST", "/library-roots", { + name: "Alist 电影", + localPath: `/@alist/${c.id}/s1/media`, + mediaKind: "movie", + }); + expect(lib.status).toBe(200); + const libRow = (await json(lib)).data; + expect(libRow.sentinelFile).toBeNull(); + const missing = await admin("POST", "/library-roots", { + name: "不存在的目录", + localPath: `/@alist/${c.id}/s1/nope`, + }); + expect(missing.status).toBe(412); + const blocked = await admin("DELETE", `/storage-instances/${c.id}`); + expect(blocked.status).toBe(409); + const test = (await json(await admin("POST", `/library-roots/${libRow.id}/test`))).data; + expect(test.ok).toBe(true); + await admin("DELETE", `/library-roots/${libRow.id}`); + const del = await admin("DELETE", `/storage-instances/${c.id}`); + expect(del.status).toBe(200); + await expect(h.ctx.secrets.resolve(c.secretRef)).rejects.toThrow(); + const audits = await h.ctx.db + .selectFrom("audit_event") + .select("action") + .where("action", "like", "storage_instance.%") + .execute(); + expect(new Set(audits.map((a) => a.action))).toEqual( + new Set([ + "storage_instance.create", + "storage_instance.update", + "storage_instance.test", + "storage_instance.delete", + ]), + ); + }); +}); diff --git a/packages/adapters/src/fs/ffprobe.ts b/packages/adapters/src/fs/ffprobe.ts index 43f299e7..b14f6afd 100644 --- a/packages/adapters/src/fs/ffprobe.ts +++ b/packages/adapters/src/fs/ffprobe.ts @@ -49,6 +49,8 @@ export interface ProbeOptions { /** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */ bin?: string; timeoutMs?: number; + /** 输入前的附加参数(远端直链用 -rw_timeout 等) */ + inputArgs?: string[]; } const optNum = (v: unknown): number | undefined => { @@ -78,7 +80,7 @@ export function ffprobe(path: string, opts: ProbeOptions = {}): Promise { execFile( opts.bin ?? "ffprobe", - ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", path], + ["-v", "error", ...(opts.inputArgs ?? []), "-print_format", "json", "-show_format", "-show_streams", "--", path], { timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) { diff --git a/packages/contracts/src/api/instances.ts b/packages/contracts/src/api/instances.ts index d079c192..579649c4 100644 --- a/packages/contracts/src/api/instances.ts +++ b/packages/contracts/src/api/instances.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { IdParam } from "../common.ts"; import * as instances from "../instances.ts"; +import * as storage from "../storage.ts"; import { crud, del, ep, instanceTest, R } from "./shared.ts"; export const instancesApi = { @@ -54,6 +55,27 @@ export const instancesApi = { test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"), remove: del("/media-server-instances/{id}", "删除媒体服务器实例"), }, + storageInstances: { + ...crud( + "/storage-instances", + "存储实例", + { + row: storage.StorageInstance, + create: storage.StorageInstanceCreate, + update: storage.StorageInstanceUpdate, + }, + R, + ), + test: ep({ + method: "post", + path: "/storage-instances/{id}/test", + scope: "admin", + summary: "测试 Alist / OpenList 连接(版本、登录用户、根路径、容量)", + params: IdParam, + response: storage.StorageTestResult, + }), + remove: del("/storage-instances/{id}", "删除存储实例"), + }, pathMappings: { ...crud( "/path-mappings", diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 4466c91e..2bdbcc3b 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -225,6 +225,10 @@ export const AuditAction = z.enum([ "media_server_instance.update", "media_server_instance.delete", "media_server_instance.test", + "storage_instance.create", + "storage_instance.update", + "storage_instance.delete", + "storage_instance.test", "path_mapping.create", "path_mapping.update", "path_mapping.delete", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b0733398..e5e43e13 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -31,5 +31,6 @@ export * from "./redact.ts"; export * from "./scrape.ts"; export * from "./settings.ts"; export * from "./sources.ts"; +export * from "./storage.ts"; export * from "./subtitles.ts"; export * from "./system-resources.ts"; diff --git a/packages/contracts/src/ops.ts b/packages/contracts/src/ops.ts index b703f4dd..b09f7844 100644 --- a/packages/contracts/src/ops.ts +++ b/packages/contracts/src/ops.ts @@ -168,6 +168,7 @@ export const SecretPurpose = z.enum([ "media_server_api_key", "metadata_api_key", "llm_api_key", + "storage_credential", "other", ]); export type SecretPurpose = z.infer; @@ -448,7 +449,8 @@ export const SystemDisk = z.object({ export type SystemDisk = z.infer; export const SystemMount = z.object({ - kind: z.enum(["library_root", "path_mapping"]), + /** storage_instance = Alist / OpenList 实例(连通 + 根路径可访问;令牌失效为 offline/auth) */ + kind: z.enum(["library_root", "path_mapping", "storage_instance"]), id: z.string(), name: z.string(), path: z.string(), diff --git a/packages/contracts/src/storage.ts b/packages/contracts/src/storage.ts new file mode 100644 index 00000000..39950692 --- /dev/null +++ b/packages/contracts/src/storage.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { EntityBase, Id, JsonObject, Timestamp, WriteOnlySecret } from "./common.ts"; +import { InstanceHealth } from "./instances.ts"; + +// Alist / OpenList 存储实例(docs/design/storage-alist.md §3)。凭据(令牌 / 密码)与路径密码只写不读。 + +export const StorageKind = z.enum(["alist"]); +export type StorageKind = z.infer; +/** 只用于展示与版本差异;接口相同(MP 的 AlistGo 是同实现的子类) */ +export const StorageFlavor = z.enum(["alist", "openlist"]); +export type StorageFlavor = z.infer; +/** token 永久令牌(Alist 后台「设置 → 其他 → 令牌」);password 账号密码换临时令牌;guest 访客 */ +export const StorageAuthKind = z.enum(["token", "password", "guest"]); +export type StorageAuthKind = z.infer; + +/** 实例高级选项;不填用缺省(括号内) */ +export const StorageOptions = z + .object({ + /** 每秒请求数(5);0 不限 */ + qps: z.number().int().min(0).max(100), + /** 并发(4);0 不限 */ + concurrency: z.number().int().min(0).max(32), + /** 普通请求超时秒(30) */ + timeoutSec: z.number().int().min(5).max(600), + /** 上传 / 下载无进度空闲超时秒(120) */ + idleTimeoutSec: z.number().int().min(10).max(3600), + /** 等待 Alist 复制 / 移动 / 上传任务的上限分钟(360) */ + taskTimeoutMin: z.number().int().min(1).max(10080), + /** 可重试错误的重试次数(3) */ + retries: z.number().int().min(0).max(10), + /** 上传用 As-Task(false,同 MP) */ + uploadAsTask: z.boolean(), + /** 目录监控最大深度(5,同 MP) */ + snapshotMaxDepth: z.number().int().min(1).max(20), + /** 目录监控按目录修改时间跳过未变子树(true) */ + snapshotCheckFolderModtime: z.boolean(), + }) + .partial(); +export type StorageOptions = z.infer; + +export const STORAGE_OPTION_DEFAULTS: Required = { + qps: 5, + concurrency: 4, + timeoutSec: 30, + idleTimeoutSec: 120, + taskTimeoutMin: 360, + retries: 3, + uploadAsTask: false, + snapshotMaxDepth: 5, + snapshotCheckFolderModtime: true, +}; + +/** Alist 内路径:以 / 开头,不含 .. 段 */ +export const AlistPath = z + .string() + .max(4096) + .refine((p) => p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), { + message: "Alist 路径必须以 / 开头,不能含 . 或 ..", + }); + +export const StorageInstance = EntityBase.extend({ + name: z.string().min(1).max(100), + kind: StorageKind, + flavor: StorageFlavor, + baseUrl: z.url(), + /** 本系统可浏览 / 使用的 Alist 根路径(缺省 /) */ + rootPath: AlistPath, + authKind: StorageAuthKind, + username: z.string().nullable(), + secretRef: Id.nullable(), + pathPasswordRef: Id.nullable(), + enabled: z.boolean(), + options: StorageOptions, + healthStatus: InstanceHealth, + lastHealthAt: Timestamp.nullable(), + healthDetail: JsonObject, + versionInfo: JsonObject, +}); +export type StorageInstance = z.infer; + +export const StorageInstanceCreate = StorageInstance.pick({ + name: true, + flavor: true, + baseUrl: true, + rootPath: true, + authKind: true, + username: true, + enabled: true, + options: true, +}) + .partial({ flavor: true, rootPath: true, authKind: true, username: true, enabled: true, options: true }) + .extend({ + /** 令牌(authKind=token)或密码(authKind=password);只写 */ + credential: WriteOnlySecret(4096).optional(), + /** 路径密码;只写 */ + pathPassword: WriteOnlySecret(1024).optional(), + }); +export type StorageInstanceCreate = z.infer; +/** 更新:credential / pathPassword 省略或空串 = 不变,null = 清除 */ +export const StorageInstanceUpdate = StorageInstanceCreate.partial().extend({ + credential: WriteOnlySecret(4096).nullable().optional(), + pathPassword: WriteOnlySecret(1024).nullable().optional(), +}); +export type StorageInstanceUpdate = z.infer; + +export const StorageUsage = z.object({ total: z.number(), free: z.number(), used: z.number() }); +export type StorageUsage = z.infer; + +export const StorageTestResult = z.object({ + ok: z.boolean(), + healthStatus: InstanceHealth, + version: z.string().nullable(), + /** 登录用户(访客为 guest) */ + user: z.string().nullable(), + /** 根路径存在且是目录 */ + rootOk: z.boolean(), + /** 容量;驱动不给或令牌不是管理员时为 null(显示「未知」) */ + usage: StorageUsage.nullable(), + message: z.string().nullable(), +}); +export type StorageTestResult = z.infer; diff --git a/packages/db/migrations/0090_storage_instance.ts b/packages/db/migrations/0090_storage_instance.ts new file mode 100644 index 00000000..9f326395 --- /dev/null +++ b/packages/db/migrations/0090_storage_instance.ts @@ -0,0 +1,62 @@ +import type { Kysely } from "kysely"; +import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts"; + +// Alist / OpenList 存储实例(docs/design/storage-alist.md §3):远端路径用虚拟前缀 /@alist//<路径> 表示, +// 库根 local_path、整理记录、资产位置里都直接存这个路径,不需要新列。凭据走 secret 表(purpose storage_credential)。 + +const PURPOSE_OLD = [ + "site_cookie", + "site_passkey", + "site_api_key", + "site_password", + "feed_url", + "downloader_password", + "media_server_api_key", + "metadata_api_key", + "llm_api_key", + "other", +]; +const PURPOSE_NEW = [...PURPOSE_OLD.slice(0, -1), "storage_credential", "other"]; +const HEALTH = ["unknown", "healthy", "degraded", "offline"]; + +const swapPurpose = (values: readonly string[]) => ` +alter table secret drop constraint if exists secret_purpose_check; +alter table secret add constraint secret_purpose_check ${oneOf("purpose", values)}; +`; + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, swapPurpose(PURPOSE_NEW)); + await execDdl( + db, + entityTable( + "storage_instance", + ` + name text not null unique, + kind text not null default 'alist' ${oneOf("kind", ["alist"])}, + flavor text not null default 'openlist' ${oneOf("flavor", ["alist", "openlist"])}, + base_url text not null, + root_path text not null default '/' check (root_path like '/%'), + auth_kind text not null default 'token' ${oneOf("auth_kind", ["token", "password", "guest"])}, + username text, + secret_ref uuid references secret(id) on delete set null, + path_password_ref uuid references secret(id) on delete set null, + enabled boolean not null default true, + options jsonb not null default '{}'::jsonb, + health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)}, + last_health_at timestamptz, + health_detail jsonb not null default '{}'::jsonb, + version_info jsonb not null default '{}'::jsonb`, + ), + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + `drop table if exists storage_instance cascade; + delete from secret where purpose = 'storage_credential'; + ${swapPurpose(PURPOSE_OLD)}`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 3bbf065e..e6608c7c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -246,6 +246,7 @@ export type SecretPurpose = | "media_server_api_key" | "metadata_api_key" | "llm_api_key" + | "storage_credential" | "other"; export type ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other"; export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale"; @@ -260,6 +261,10 @@ export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual export type SiteSnapshotStatus = "running" | "ok" | "partial" | "failed"; export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected"; export type SourceReleaseAvailability = "active" | "site_disabled" | "expired" | "unknown"; +export type StorageInstanceKind = "alist"; +export type StorageInstanceFlavor = "alist" | "openlist"; +export type StorageInstanceAuthKind = "token" | "password" | "guest"; +export type StorageInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline"; export type SubscriptionMediaKind = "movie" | "series" | "variety"; export type SubscriptionMode = "once" | "continuous"; export type SubscriptionSeasonScope = "current_season" | "following_seasons"; @@ -1635,6 +1640,27 @@ export interface SseEventTable { payload: Generated; } +export interface StorageInstanceTable { + id: Generated; + name: string; + kind: Generated; + flavor: Generated; + base_url: string; + root_path: Generated; + auth_kind: Generated; + username: string | null; + secret_ref: string | null; + path_password_ref: string | null; + enabled: Generated; + options: Generated; + health_status: Generated; + last_health_at: Timestamptz | null; + health_detail: Generated; + version_info: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface SubscriptionTable { id: Generated; media_entity_id: string | null; @@ -1869,6 +1895,7 @@ export interface DB { slot_unit: SlotUnitTable; source_release: SourceReleaseTable; sse_event: SseEventTable; + storage_instance: StorageInstanceTable; subscription: SubscriptionTable; subscription_history: SubscriptionHistoryTable; subtitle_write: SubtitleWriteTable; @@ -2115,6 +2142,9 @@ export type SourceReleaseUpdate = Updateable; export type SseEventRow = Selectable; export type NewSseEvent = Insertable; export type SseEventUpdate = Updateable; +export type StorageInstanceRow = Selectable; +export type NewStorageInstance = Insertable; +export type StorageInstanceUpdate = Updateable; export type SubscriptionRow = Selectable; export type NewSubscription = Insertable; export type SubscriptionUpdate = Updateable;