Files
UHD_Media_Tool/apps/server/src/modules/auth-settings/instances-routes.ts
T
rootandClaude Opus 5.5 ff230e5da7 feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
  (migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
  hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
  SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
  CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
  sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
  {type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
  to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
  downloader/media-server instances (+test), path-mappings (+verify),
  PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
  seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
  admin APIs against mock qB/Emby, SSE across two Deno subprocesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:59:54 +08:00

558 lines
20 KiB
TypeScript

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<string, unknown> & { 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<string, unknown>) =>
// biome-ignore lint/suspicious/noExplicitAny: 同上
(await (db() as any)
.insertInto(table)
.values(values)
.returningAll()
.executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & {
id: string;
};
const update = async (table: Table, id: string, values: Record<string, unknown>) => {
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<string, unknown> & { 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<typeof InstanceTestResult>;
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<typeof InstanceTestResult>;
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<string, unknown>;
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<SiteCredentialsPut["authKind"], SecretPurpose> = {
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<string, unknown> | undefined) ?? {}),
...(body.username ? { username: body.username } : {}),
};
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
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,
};
},
);
}