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; }