243 lines
9.1 KiB
TypeScript
243 lines
9.1 KiB
TypeScript
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("/")}` : "/";
|
||
}
|