diff --git a/.env.example b/.env.example
index 98298ec..316c94b 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,6 @@ OPEN_DANMAKU_APP_SECRET=
# 可选:Bangumi API 地址(默认 https://api.bgm.tv)
# BANGUMI_API_BASE=https://api.bgm.tv
+
+# WebDAV 挂载威胁模型:仅允许 http(s);拒绝 localhost/127.*、169.254.*(云 metadata)。
+# 局域网私网(192.168.* 等)允许,便于本机 OpenList/群晖;公网部署请自行加出站策略。
diff --git a/app/web/src/components/pages/BrowseBody.tsx b/app/web/src/components/pages/BrowseBody.tsx
index 34103d7..190d929 100644
--- a/app/web/src/components/pages/BrowseBody.tsx
+++ b/app/web/src/components/pages/BrowseBody.tsx
@@ -1,12 +1,11 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
+import { isVideoFilename } from "@app/types";
import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
-const VIDEO_RE = /\.(mp4|mkv|webm|avi|mov|m4v|ts|flv|wmv|mpg|mpeg)$/i;
-
function pathOf(segments: string[]): string {
return segments.length > 0 ? `/${segments.join("/")}` : "/";
}
@@ -104,7 +103,9 @@ function BrowseInner(props: { locale: Locale }) {
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
)}
- {scan.isError && {scan.error.message}
}
+ {scan.isError && (
+ {t(locale, "browse.scanError")}
+ )}
))}
- {progressList.length === 0 && (
+ {progressList.length === 0 && !list.isPending && (
{t(locale, "library.empty")}
)}
+ {hasMore && (
+
+
+
+ )}
{matchId && (
@@ -144,9 +166,9 @@ function LibraryInner(props: { locale: Locale }) {
}
const client = createTRPCReactClient();
-const qc = new QueryClient();
export function LibraryBody(props: { locale: Locale }) {
+ const [qc] = useState(() => new QueryClient());
return (
diff --git a/app/web/src/components/pages/MountsBody.tsx b/app/web/src/components/pages/MountsBody.tsx
index e440779..7784144 100644
--- a/app/web/src/components/pages/MountsBody.tsx
+++ b/app/web/src/components/pages/MountsBody.tsx
@@ -130,9 +130,8 @@ function MountsInner(props: { locale: Locale }) {
}
const client = createTRPCReactClient();
-const qc = new QueryClient();
-
export function MountsBody(props: { locale: Locale }) {
+ const [qc] = useState(() => new QueryClient());
return (
diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx
index 016893d..9918270 100644
--- a/app/web/src/components/pages/WatchBody.tsx
+++ b/app/web/src/components/pages/WatchBody.tsx
@@ -135,9 +135,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const input = snapshot();
if (input) reportRef.current.mutate(input);
};
+ let unloaded = false;
const flushUnload = () => {
+ if (unloaded) return;
const input = snapshot();
if (!input) return;
+ unloaded = true;
reportRef.current.mutate(input);
sendProgressBeacon(input);
};
@@ -321,9 +324,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}
const client = createTRPCReactClient();
-const qc = new QueryClient();
-
export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
+ const [qc] = useState(() => new QueryClient());
return (
diff --git a/packages/dao/src/media-items.ts b/packages/dao/src/media-items.ts
index cafe606..4afbf21 100644
--- a/packages/dao/src/media-items.ts
+++ b/packages/dao/src/media-items.ts
@@ -75,7 +75,6 @@ export const mediaItemDao = {
const [row] = await db
.update(mediaItems)
.set({
- mountId: data.mountId,
size: data.size,
mime: data.mime,
updatedAt: new Date(),
diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts
index db21124..f6a0947 100644
--- a/packages/i18n/src/messages/en.ts
+++ b/packages/i18n/src/messages/en.ts
@@ -47,6 +47,7 @@ export const en: Record = {
"library.play": "Play",
"library.match": "Match",
"library.empty": "No media yet. Scan a mount in the file browser first.",
+ "library.loadMore": "Load more",
"library.matchTitle": "Manual Bangumi match",
"library.searchWork": "Search by title",
"library.bind": "Bind",
@@ -64,6 +65,7 @@ export const en: Record = {
"browse.loadError": "Failed to read directory",
"browse.playError": "Failed to add to library",
"browse.scanDone": "Scan complete: {count} files",
+ "browse.scanError": "Scan failed. Check the mount configuration.",
"mounts.title": "Mounts",
"mounts.add": "Add WebDAV",
diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts
index e88a77c..7a347c9 100644
--- a/packages/i18n/src/messages/zh-CN.ts
+++ b/packages/i18n/src/messages/zh-CN.ts
@@ -48,6 +48,7 @@ export const zhCN = {
"library.play": "播放",
"library.match": "匹配",
"library.empty": "暂无媒体,请先在文件浏览中扫描挂载。",
+ "library.loadMore": "加载更多",
"library.matchTitle": "手动匹配 Bangumi",
"library.searchWork": "搜索作品名",
"library.bind": "绑定",
@@ -65,6 +66,7 @@ export const zhCN = {
"browse.loadError": "目录读取失败",
"browse.playError": "入库失败,请重试",
"browse.scanDone": "扫描完成:{count} 个文件",
+ "browse.scanError": "扫描失败,请检查挂载配置",
"mounts.title": "挂载管理",
"mounts.add": "添加 WebDAV",
diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts
index 9c72dec..8b1e3c0 100644
--- a/packages/trpc/src/services/danmaku.service.ts
+++ b/packages/trpc/src/services/danmaku.service.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
+import { TRPCError } from "@trpc/server";
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
import type { DanmakuFetchOutput } from "@app/types";
import { decryptSecret } from "./secret.js";
@@ -148,7 +149,8 @@ async function fetchOpenNetworkXml(
};
if (matched.success === false) return null;
const episodeId = matched.matches?.[0]?.episodeId;
- if (!episodeId || !matched.isMatched) return null;
+ if (!episodeId) return null;
+ if (matched.isMatched === false) return null;
const commentPath = `/api/v2/comment/${episodeId}`;
const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, {
@@ -192,11 +194,13 @@ export const danmakuService = {
return { ok: true, source: "open-network", xml };
},
- /** 本地 XML 由前端解析后直接喂播放器;此处仅确认媒体存在并记 size 日志约束 */
+ /** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
- if (!item) return Promise.reject(new Error("媒体不存在"));
- if (byteSize > 20_000_000) return Promise.reject(new Error("XML 过大"));
+ if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
+ if (byteSize > 20_000_000) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
+ }
return { ok: true };
},
};
diff --git a/packages/trpc/src/services/mount.service.ts b/packages/trpc/src/services/mount.service.ts
index 7f6ebc1..dc80009 100644
--- a/packages/trpc/src/services/mount.service.ts
+++ b/packages/trpc/src/services/mount.service.ts
@@ -2,7 +2,13 @@ import { TRPCError } from "@trpc/server";
import { mountDao, type MountRow } from "@app/dao";
import type { MountCreateInput, MountListDirInput } from "@app/types";
import { decryptSecret, encryptSecret } from "./secret.js";
-import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
+import {
+ assertSafeWebdavUrl,
+ createWebdav,
+ joinWebdavPath,
+ listDirectory,
+ type DirEntry,
+} from "./webdav-client.js";
export type MountPublic = {
id: string;
@@ -50,6 +56,14 @@ export const mountService = {
},
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,
@@ -62,8 +76,17 @@ export const mountService = {
enabled: input.enabled,
});
return { mount: toPublic(row) };
- } catch {
- throw new TRPCError({ code: "CONFLICT", message: "挂载名称已存在或创建失败" });
+ } 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,
+ });
}
},
@@ -81,7 +104,17 @@ export const mountService = {
): Promise<{ mount: MountPublic }> {
const patch: Record = {};
if (input.name !== undefined) patch["name"] = input.name;
- if (input.baseUrl !== undefined) patch["baseUrl"] = input.baseUrl;
+ 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 !== undefined) {
patch["secretEnc"] = input.password ? encryptSecret(input.password) : "";
@@ -104,6 +137,11 @@ export const mountService = {
password?: string | undefined;
rootPath: string;
}): Promise<{ ok: boolean; message: string }> {
+ try {
+ assertSafeWebdavUrl(input.baseUrl);
+ } catch (e) {
+ return { ok: false, message: e instanceof Error ? e.message : "地址不合法" };
+ }
try {
const client = createWebdav({
baseUrl: input.baseUrl,
@@ -112,8 +150,8 @@ export const mountService = {
});
await listDirectory(client, input.rootPath || "/");
return { ok: true, message: "连接成功" };
- } catch (e) {
- return { ok: false, message: e instanceof Error ? e.message : "连接失败" };
+ } catch {
+ return { ok: false, message: "连接失败" };
}
},
@@ -131,10 +169,10 @@ export const mountService = {
return a.basename.localeCompare(b.basename);
});
return { entries, path: input.path };
- } catch (e) {
+ } catch {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
- message: e instanceof Error ? `读取目录失败: ${e.message}` : "读取目录失败",
+ message: "读取目录失败",
});
}
},
diff --git a/packages/trpc/src/services/playback.service.ts b/packages/trpc/src/services/playback.service.ts
index 6d3291d..3547ca7 100644
--- a/packages/trpc/src/services/playback.service.ts
+++ b/packages/trpc/src/services/playback.service.ts
@@ -1,8 +1,11 @@
-import { playbackProgressDao } from "@app/dao";
+import { TRPCError } from "@trpc/server";
+import { mediaItemDao, playbackProgressDao } from "@app/dao";
import type { PlaybackReportInput } from "@app/types";
export const playbackService = {
async report(userId: string, input: PlaybackReportInput) {
+ const item = await mediaItemDao.getByIdForUser(input.mediaItemId, userId);
+ if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
const row = await playbackProgressDao.upsert(
userId,
input.mediaItemId,
diff --git a/packages/trpc/src/services/webdav-client.test.ts b/packages/trpc/src/services/webdav-client.test.ts
index 193f30e..c87a191 100644
--- a/packages/trpc/src/services/webdav-client.test.ts
+++ b/packages/trpc/src/services/webdav-client.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { joinWebdavPath, isVideoFilename } from "./webdav-client.js";
+import { assertSafeWebdavUrl, isVideoFilename, joinWebdavPath } from "./webdav-client.js";
describe("webdav helpers", () => {
it("joinWebdavPath", () => {
@@ -11,3 +11,17 @@ describe("webdav helpers", () => {
expect(isVideoFilename("readme.txt")).toBe(false);
});
});
+
+describe("assertSafeWebdavUrl", () => {
+ it("allows lan https/http", () => {
+ expect(() => assertSafeWebdavUrl("https://dav.example.com/dav")).not.toThrow();
+ expect(() => assertSafeWebdavUrl("http://192.168.1.10:5244/dav")).not.toThrow();
+ });
+ it("rejects loopback and metadata", () => {
+ expect(() => assertSafeWebdavUrl("http://localhost:5244")).toThrow();
+ expect(() => assertSafeWebdavUrl("http://127.0.0.1/dav")).toThrow();
+ expect(() => assertSafeWebdavUrl("http://169.254.169.254/")).toThrow();
+ expect(() => assertSafeWebdavUrl("ftp://x.example/")).toThrow();
+ expect(() => assertSafeWebdavUrl("not-a-url")).toThrow();
+ });
+});
diff --git a/packages/trpc/src/services/webdav-client.ts b/packages/trpc/src/services/webdav-client.ts
index f088410..fde49fd 100644
--- a/packages/trpc/src/services/webdav-client.ts
+++ b/packages/trpc/src/services/webdav-client.ts
@@ -1,23 +1,34 @@
import { createClient, type WebDAVClient } from "webdav";
+import { isVideoFilename } from "@app/types";
-const VIDEO_EXT = new Set([
- ".mp4",
- ".mkv",
- ".webm",
- ".avi",
- ".mov",
- ".m4v",
- ".ts",
- ".flv",
- ".wmv",
- ".mpg",
- ".mpeg",
-]);
+export { isVideoFilename };
-export function isVideoFilename(name: string): boolean {
- const i = name.lastIndexOf(".");
- if (i < 0) return false;
- return VIDEO_EXT.has(name.slice(i).toLowerCase());
+/**
+ * SSRF 基线:仅允许 http(s);拒绝 localhost/回环/链路本地(云 metadata)。
+ * 局域网私网段仍允许(自部署 WebDAV 常见);公网部署需额外网段策略。
+ */
+export function assertSafeWebdavUrl(baseUrl: string): void {
+ let u: URL;
+ try {
+ u = new URL(baseUrl);
+ } catch {
+ throw new Error("挂载地址不是合法 URL");
+ }
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
+ throw new Error("挂载地址仅支持 http/https");
+ }
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
+ if (
+ host === "localhost" ||
+ host.endsWith(".localhost") ||
+ host === "0.0.0.0" ||
+ host === "::1" ||
+ host === "0" ||
+ /^127\./.test(host) ||
+ /^169\.254\./.test(host)
+ ) {
+ throw new Error("挂载地址不允许指向本机或链路本地");
+ }
}
export function joinWebdavPath(root: string, rel: string): string {
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 9ffdd71..cf3651d 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -1,3 +1,4 @@
/** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */
export * from "./schemas.js";
export * from "./media-schemas.js";
+export * from "./media-fs.js";
diff --git a/packages/types/src/media-fs.ts b/packages/types/src/media-fs.ts
new file mode 100644
index 0000000..881fe7f
--- /dev/null
+++ b/packages/types/src/media-fs.ts
@@ -0,0 +1,21 @@
+/** 视频扩展名与文件名判定(types 纯函数,Web 与 WebDAV 客户端共用)。 */
+export const VIDEO_EXTENSIONS = [
+ "mp4",
+ "mkv",
+ "webm",
+ "avi",
+ "mov",
+ "m4v",
+ "ts",
+ "flv",
+ "wmv",
+ "mpg",
+ "mpeg",
+] as const;
+
+export function isVideoFilename(name: string): boolean {
+ const i = name.lastIndexOf(".");
+ if (i < 0) return false;
+ const ext = name.slice(i + 1).toLowerCase();
+ return (VIDEO_EXTENSIONS as readonly string[]).includes(ext);
+}