import { stat } from "node:fs/promises"; import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters"; import { Api, type AuditAction, DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate, IdParam, InstanceTestResult, LibraryRoot, LibraryRootCreate, LibraryRootUpdate, MediaServerInstance, MediaServerInstanceCreate, MediaServerInstanceUpdate, PathMapping, PathMappingCreate, PathMappingUpdate, type SecretPurpose, SiteCredentialsPut, SiteCredentialsResult, } from "@umt/contracts"; import type { DB } from "@umt/db"; import { z } from "zod"; import { ApiException } from "../../http/errors.ts"; import { redact, redactString } from "../../logger.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { afterCursor, audit, crudDefs, cursorCol, type Def, decodeCursor, endpoint, pageOf, pgConflict, toApi, toDb, } from "./endpoint.ts"; // 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试, // 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。 const OkResult = z.object({ ok: z.literal(true) }); const del = (path: string, summary: string, tag: string) => ({ method: "delete" as const, path, scope: "admin" as const, summary, params: IdParam, response: OkResult, tags: [tag], }); const test = (path: string, summary: string, tag: string) => ({ method: "post" as const, path, scope: "admin" as const, summary, params: IdParam, response: InstanceTestResult, tags: [tag], }); type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping"; const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const; const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500); const LR = crudDefs("/library-roots", "媒体库根", { row: LibraryRoot, create: LibraryRootCreate, update: LibraryRootUpdate, }); const DL = crudDefs("/downloader-instances", "下载器实例", { row: DownloaderInstance, create: DownloaderInstanceCreate, update: DownloaderInstanceUpdate, }); const MS = crudDefs("/media-server-instances", "媒体服务器实例", { row: MediaServerInstance, create: MediaServerInstanceCreate, update: MediaServerInstanceUpdate, }); const PM = crudDefs("/path-mappings", "路径映射", { row: PathMapping, create: PathMappingCreate, update: PathMappingUpdate, }); export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) { const db = () => ctx.db; /** 通用列表 / 详情 / 删除 */ const list = (table: Table, tag: string, def: Def) => endpoint(app, { ...def, tags: [tag] }, async ({ query }) => { const q0 = query as { cursor?: string; limit: number }; const cur = decodeCursor(q0.cursor); // biome-ignore lint/suspicious/noExplicitAny: 四张表共用的通用查询 let q = (db() as any).selectFrom(table).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 { cursor_us: number; id: string }[], q0.limit); return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor }; }); const getRow = async (table: Table, id: string) => { // biome-ignore lint/suspicious/noExplicitAny: 同上 const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst(); if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`); return r as Record & { id: string; secret_ref?: string | null }; }; const get = (table: Table, tag: string, def: Def) => endpoint(app, { ...def, tags: [tag] }, async ({ params }) => toApi(await getRow(table, (params as { id: string }).id)), ); const remove = (table: Table, tag: string, path: string, action: AuditAction) => endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => { const id = (params as { id: string }).id; const row = await getRow(table, id); // biome-ignore lint/suspicious/noExplicitAny: 同上 await (db() as any) .deleteFrom(table) .where("id", "=", id) .execute() .catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`)); if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {}); await audit(ctx, actor, action, { type: table, id }); return { ok: true as const }; }); const insert = async (table: Table, values: Record) => // biome-ignore lint/suspicious/noExplicitAny: 同上 (await (db() as any) .insertInto(table) .values(values) .returningAll() .executeTakeFirstOrThrow() .catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record & { id: string; }; const update = async (table: Table, id: string, values: Record) => { if (!Object.keys(values).length) return await getRow(table, id); // biome-ignore lint/suspicious/noExplicitAny: 同上 const r = await (db() as any) .updateTable(table) .set(values) .where("id", "=", id) .returningAll() .executeTakeFirst() .catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`)); if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`); return r as Record & { id: string }; }; /** 凭证:有旧 ref 就原地替换,否则新建 */ const putCredential = async (oldRef: string | null | undefined, purpose: SecretPurpose, plaintext: string) => { if (oldRef) { await ctx.secrets.replace(oldRef, plaintext); return oldRef; } return await ctx.secrets.create({ purpose, plaintext }); }; // ── library_root ── const sentinel = async (localPath: string, file: string | null | undefined) => { const name = file || DEFAULT_ROOT_SENTINEL; try { const st = await stat(localPath); if (!st.isDirectory()) throw new Error("不是目录"); await writeRootSentinel(localPath, name); } catch (e) { throw new ApiException( "precondition_failed", `库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`, ); } return name; }; list("library_root", "library-roots", LR.list); get("library_root", "library-roots", LR.get); endpoint(app, LR.create, async ({ body, actor }) => { const file = await sentinel(body.localPath, body.sentinelFile); const row = await insert("library_root", { ...toDb(body, JSON_COLS), sentinel_file: file, health_status: "healthy", last_health_at: new Date(), }); await audit(ctx, actor, "library_root.create", { type: "library_root", id: row.id }, { localPath: body.localPath }); return toApi(row); }); endpoint(app, LR.update, async ({ params, body, actor }) => { const values = toDb(body, JSON_COLS); if (body.localPath !== undefined || body.sentinelFile !== undefined) { const cur = await getRow("library_root", params.id); values.sentinel_file = await sentinel( body.localPath ?? String(cur.local_path), body.sentinelFile ?? (cur.sentinel_file as string | null), ); } const row = await update("library_root", params.id, values); await audit(ctx, actor, "library_root.update", { type: "library_root", id: row.id }, { fields: Object.keys(body) }); return toApi(row); }); remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete"); endpoint( app, 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 ok = h.kind === "online"; await update("library_root", row.id, { health_status: ok ? "healthy" : "offline", last_health_at: new Date(), health_detail: JSON.stringify(h), }); await audit(ctx, actor, "library_root.test", { type: "library_root", id: row.id }, { ok }); return { ok, version: null, message: ok ? null : `offline: ${"reason" in h ? h.reason : "unknown"}`, healthStatus: ok ? "healthy" : "offline", }; }, ); // ── downloader_instance ── list("downloader_instance", "downloader-instances", DL.list); get("downloader_instance", "downloader-instances", DL.get); endpoint(app, DL.create, async ({ body, actor }) => { const { credential, ...rest } = body; const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null; try { const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref }); await audit( ctx, actor, "downloader_instance.create", { type: "downloader_instance", id: row.id }, { name: row.name }, ); return toApi(row); } catch (e) { if (ref) await ctx.secrets.remove(ref).catch(() => {}); throw e; } }); endpoint(app, DL.update, async ({ params, body, actor }) => { const { credential, ...rest } = body; const values = toDb(rest, JSON_COLS); if (credential) { const cur = await getRow("downloader_instance", params.id); values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential); } const row = await update("downloader_instance", params.id, values); await audit( ctx, actor, "downloader_instance.update", { type: "downloader_instance", id: row.id }, { fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)), }, ); return toApi(row); }); remove("downloader_instance", "downloader-instances", "/downloader-instances/{id}", "downloader_instance.delete"); endpoint( app, { ...test("/downloader-instances/{id}/test", "测试下载器连接", "downloader-instances"), response: InstanceTestResult, }, async ({ params, actor }) => { const row = await getRow("downloader_instance", params.id); const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined; const username = (row.username as string | null) ?? undefined; // 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key const client = new QbClient({ instanceId: row.id, baseUrl: String(row.base_url), ...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}), timeoutMs: 10_000, retries: 0, }); let result: z.infer; try { const v = await client.version(); result = { ok: true, version: v.app, message: null, healthStatus: "healthy" }; await update("downloader_instance", row.id, { health_status: "healthy", last_seen_at: new Date(), version_info: JSON.stringify({ app: v.app, webApi: v.webApi }), }); } catch (e) { result = { ok: false, version: null, message: errMessage(e), healthStatus: "offline" }; await update("downloader_instance", row.id, { health_status: "offline" }); } await audit( ctx, actor, "downloader_instance.test", { type: "downloader_instance", id: row.id }, { ok: result.ok }, ); return result; }, ); // ── media_server_instance ── list("media_server_instance", "media-server-instances", MS.list); get("media_server_instance", "media-server-instances", MS.get); endpoint(app, MS.create, async ({ body, actor }) => { const { credential, ...rest } = body; const ref = credential ? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential }) : null; try { const row = await insert("media_server_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref }); await audit( ctx, actor, "media_server_instance.create", { type: "media_server_instance", id: row.id }, { name: row.name }, ); return toApi(row); } catch (e) { if (ref) await ctx.secrets.remove(ref).catch(() => {}); throw e; } }); endpoint(app, MS.update, async ({ params, body, actor }) => { const { credential, ...rest } = body; const values = toDb(rest, JSON_COLS); if (credential) { const cur = await getRow("media_server_instance", params.id); values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential); } const row = await update("media_server_instance", params.id, values); await audit( ctx, actor, "media_server_instance.update", { type: "media_server_instance", id: row.id }, { fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)), }, ); return toApi(row); }); remove( "media_server_instance", "media-server-instances", "/media-server-instances/{id}", "media_server_instance.delete", ); endpoint( app, test("/media-server-instances/{id}/test", "测试媒体服务器连接", "media-server-instances"), async ({ params, actor }) => { const row = await getRow("media_server_instance", params.id); let result: z.infer; if (!row.secret_ref) { result = { ok: false, version: null, message: "未配置 API Key", healthStatus: "unknown" }; } else { const client = new EmbyClient({ instanceId: row.id, baseUrl: String(row.base_url), apiKey: await ctx.secrets.resolve(row.secret_ref), flavor: row.kind === "jellyfin" ? "jellyfin" : "emby", ...(row.user_id ? { userId: String(row.user_id) } : {}), timeoutMs: 10_000, retries: 0, }); const r = await client.ping(); if (r.status === "ok") { result = { ok: true, version: r.value.version ?? null, message: r.value.serverName ?? null, healthStatus: "healthy", }; await update("media_server_instance", row.id, { health_status: "healthy", last_seen_at: new Date(), version_info: JSON.stringify(redact(r.value)), }); } else { result = { ok: false, version: null, message: `${r.reason}: ${redactString(r.message)}`, healthStatus: "offline", }; await update("media_server_instance", row.id, { health_status: "offline" }); } } await audit( ctx, actor, "media_server_instance.test", { type: "media_server_instance", id: row.id }, { ok: result.ok }, ); return result; }, ); // ── path_mapping ── list("path_mapping", "path-mappings", PM.list); get("path_mapping", "path-mappings", PM.get); endpoint(app, PM.create, async ({ body, actor }) => { const row = await insert("path_mapping", toDb(body, JSON_COLS)); await audit( ctx, actor, "path_mapping.create", { type: "path_mapping", id: row.id }, { remotePrefix: body.remotePrefix, localPrefix: body.localPrefix, }, ); return toApi(row); }); endpoint(app, PM.update, async ({ params, body, actor }) => { const values = toDb(body, JSON_COLS); if (body.localPrefix !== undefined || body.remotePrefix !== undefined) values.verify_status = "unverified"; const row = await update("path_mapping", params.id, values); await audit(ctx, actor, "path_mapping.update", { type: "path_mapping", id: row.id }, { fields: Object.keys(body) }); return toApi(row); }); endpoint(app, { ...Api.pathMappings.remove, tags: ["path-mappings"] }, async ({ params, actor }) => { const r = await db().deleteFrom("path_mapping").where("id", "=", params.id).returning("id").executeTakeFirst(); if (!r) throw new ApiException("not_found", "路径映射不存在"); await audit(ctx, actor, "path_mapping.delete", { type: "path_mapping", id: params.id }); return { ok: true as const }; }); endpoint(app, { ...Api.pathMappings.verify, tags: ["path-mappings"] }, async ({ params, actor }) => { const row = await getRow("path_mapping", params.id); let detail: Record; let ok = false; try { const st = await stat(String(row.local_prefix)); ok = st.isDirectory(); detail = ok ? { localPrefix: "directory" } : { error: "localPrefix 不是目录" }; } catch (e) { detail = { error: errMessage(e), code: (e as { code?: string })?.code ?? null }; } const updated = await update("path_mapping", row.id, { verify_status: ok ? "ok" : "failed", verified_at: new Date(), verify_detail: JSON.stringify(detail), }); await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok }); return toApi(updated); }); // ── 站点账号凭证 ── const PURPOSE: Record = { cookie: "site_cookie", passkey: "site_passkey", api_key: "site_api_key", password: "site_password", }; endpoint( app, { method: "put", path: "/sites/{id}/credentials", scope: "admin", summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)", params: IdParam, body: SiteCredentialsPut, response: SiteCredentialsResult, tags: ["sites"], }, async ({ params, body, actor }) => { const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst(); if (!site) throw new ApiException("not_found", "站点不存在"); const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential }); let oldRef: string | null = null; let created = false; let accountId: string; try { accountId = await db() .transaction() .execute(async (trx) => { const cur = await trx .selectFrom("site_account") .select(["id", "secret_ref", "user_info"]) .where("site_id", "=", params.id) .where("label", "=", body.label) .forUpdate() .executeTakeFirst(); const userInfo = { ...((cur?.user_info as Record | undefined) ?? {}), ...(body.username ? { username: body.username } : {}), }; const common: Partial> = { auth_kind: body.authKind, secret_ref: newRef, status: "unknown", user_info: JSON.stringify(userInfo), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), }; if (cur) { oldRef = cur.secret_ref; await trx .updateTable("site_account") .set(common as never) .where("id", "=", cur.id) .execute(); return cur.id; } created = true; const r = await trx .insertInto("site_account") .values({ site_id: params.id, label: body.label, ...common } as never) .returning("id") .executeTakeFirstOrThrow(); return r.id; }); } catch (e) { await ctx.secrets.remove(newRef).catch(() => {}); throw e; } if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {}); await audit( ctx, actor, "site_account.credentials_set", { type: "site_account", id: accountId }, { siteId: params.id, label: body.label, authKind: body.authKind, created, }, ); return { siteAccountId: accountId, siteId: params.id, label: body.label, authKind: body.authKind, secretRef: newRef, created, }; }, ); }