app-template/packages/trpc/src/services/mount.service.ts

243 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>(
row: Pick<MountRow, "baseUrl" | "username" | "secretEnc">,
fn: (client: ReturnType<typeof createWebdav>) => Promise<T>,
): Promise<T> {
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<string, unknown> = {};
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("/")}` : "/";
}