import { TRPCError } from "@trpc/server"; import { mediaItemDao, mountDao, playbackProgressDao, type MountRow } from "@app/dao"; import type { MountCreateInput, MountListDirInput } from "@app/types"; import { decryptSecret, encryptSecret } from "./secret.js"; import { assertSafeWebdavUrl, createWebdav, joinWebdavPath, listDirectory, type DirEntry, } from "./webdav-client.js"; export type MountPublic = { id: string; name: string; type: string; baseUrl: string; username: string | null; rootPath: string; enabled: boolean; createdAt: Date | null; updatedAt: Date | null; }; function toPublic(row: MountRow): MountPublic { return { id: row.id, name: row.name, type: row.type, baseUrl: row.baseUrl, username: row.username, rootPath: row.rootPath, enabled: row.enabled, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } async function withClient( row: Pick, fn: (client: ReturnType) => Promise, ): Promise { const password = row.secretEnc ? decryptSecret(row.secretEnc) : ""; const client = createWebdav({ baseUrl: row.baseUrl, username: row.username ?? undefined, password, }); return fn(client); } export const mountService = { async list(userId: string): Promise<{ mounts: MountPublic[] }> { const rows = await mountDao.listByUser(userId); return { mounts: rows.map(toPublic) }; }, async create(userId: string, input: MountCreateInput): Promise<{ mount: MountPublic }> { try { assertSafeWebdavUrl(input.baseUrl); } catch (e) { throw new TRPCError({ code: "BAD_REQUEST", message: e instanceof Error ? e.message : "挂载地址不合法", }); } try { const row = await mountDao.create({ userId, name: input.name, type: input.type, baseUrl: input.baseUrl, username: input.username ?? null, secretEnc: input.password ? encryptSecret(input.password) : "", rootPath: input.rootPath, enabled: input.enabled, }); return { mount: toPublic(row) }; } catch (e) { if (e instanceof TRPCError) throw e; const msg = e instanceof Error ? e.message : ""; if (/unique|UNIQUE/i.test(msg)) { throw new TRPCError({ code: "CONFLICT", message: "挂载名称已存在" }); } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "创建挂载失败", cause: e, }); } }, async update( userId: string, input: { id: string; name?: string | undefined; baseUrl?: string | undefined; username?: string | undefined; password?: string | undefined; rootPath?: string | undefined; enabled?: boolean | undefined; }, ): Promise<{ mount: MountPublic }> { const patch: Record = {}; if (input.name !== undefined) patch["name"] = input.name; if (input.baseUrl !== undefined) { try { assertSafeWebdavUrl(input.baseUrl); } catch (e) { throw new TRPCError({ code: "BAD_REQUEST", message: e instanceof Error ? e.message : "挂载地址不合法", }); } patch["baseUrl"] = input.baseUrl; } if (input.username !== undefined) patch["username"] = input.username; // 密码留空/未传 = 保持原密文,避免编辑时重复输入 if (input.password) { patch["secretEnc"] = encryptSecret(input.password); } if (input.rootPath !== undefined) patch["rootPath"] = input.rootPath; if (input.enabled !== undefined) patch["enabled"] = input.enabled; const row = await mountDao.update(input.id, userId, patch); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); return { mount: toPublic(row) }; }, async delete(userId: string, id: string): Promise<{ success: true; removedItems: number }> { const mount = await mountDao.getByIdForUser(id, userId); if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); const removedIds = await mediaItemDao.deleteByMount(id, userId); await playbackProgressDao.deleteByMediaItems(removedIds); await mountDao.delete(id, userId); return { success: true, removedItems: removedIds.length }; }, async test( input: { baseUrl: string; username?: string | undefined; password?: string | undefined; rootPath: string; mountId?: string | undefined; }, userId?: string, ): Promise<{ ok: boolean; message: string }> { try { assertSafeWebdavUrl(input.baseUrl); } catch (e) { return { ok: false, message: e instanceof Error ? e.message : "地址不合法" }; } // 编辑时密码留空:复用已存密文测连,避免每次重填 let password = input.password; if (!password && input.mountId && userId) { const existing = await mountDao.getByIdForUser(input.mountId, userId); if (existing?.secretEnc) password = decryptSecret(existing.secretEnc); } try { const client = createWebdav({ baseUrl: input.baseUrl, username: input.username, password, }); const root = normalizeWebdavPath(input.rootPath || "/"); try { await listDirectory(client, root); return { ok: true, message: "连接成功" }; } catch (e) { // 根路径失败:再探 / 仅用于补充提示,不以 / 的成败作为结论 // (部分 NAS 禁止列 /,或 / 与共享根权限不同) try { await listDirectory(client, "/"); } catch { // 忽略 } return { ok: false, message: describeWebdavError(e, root) }; } } catch (e) { return { ok: false, message: describeWebdavError(e, input.rootPath || "/") }; } }, async listDir( userId: string, input: MountListDirInput, ): Promise<{ entries: DirEntry[]; path: string }> { const mount = await mountDao.getByIdForUser(input.mountId, userId); if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" }); const abs = normalizeWebdavPath(joinWebdavPath(mount.rootPath, input.path)); try { const entries = await withClient(mount, (c) => listDirectory(c, abs)); entries.sort((a, b) => { if (a.type !== b.type) return a.type === "directory" ? -1 : 1; return a.basename.localeCompare(b.basename); }); return { entries, path: input.path }; } catch (e) { throw new TRPCError({ code: "BAD_GATEWAY", message: describeWebdavError(e), }); } }, }; /** 把 webdav/网络底层错误归一化成用户可读的失败原因 */ function describeWebdavError(e: unknown, path?: string): string { const raw = e instanceof Error ? e.message : String(e); const s = raw.toLowerCase(); if ( s.includes("401") || s.includes("403") || s.includes("unauthorized") || s.includes("forbidden") || s.includes("not authorized") ) return "认证失败,请检查用户名和密码"; if (s.includes("enotfound") || s.includes("eai_again") || s.includes("getaddrinfo")) return "无法解析服务器地址"; if (s.includes("econnrefused")) return "连接被拒绝,请检查地址和端口"; if (s.includes("etimedout") || s.includes("timeout") || s.includes("aborted")) return "连接超时"; if (s.includes("certificate") || s.includes("ssl") || s.includes("tls")) return "HTTPS 证书错误"; if (s.includes("400") || s.includes("bad request")) return "请求被拒绝,请检查地址和根路径"; if (s.includes("404") || s.includes("405") || s.includes("not found")) return "路径不存在,请检查 WebDAV 地址和根路径"; return "连接失败"; } /** * WebDAV 路径:只做斜杠规范化。 * 不要 encodeURIComponent —— webdav 库内部 encodePath 会再编码一次, * 双重编码(% → %25)会导致服务器 400 Bad Request。 */ function normalizeWebdavPath(input: string): string { const trimmed = (input || "/").trim().replace(/\\/g, "/"); const parts = trimmed.split("/").filter(Boolean); return parts.length ? `/${parts.join("/")}` : "/"; }