fix: caveman-review findings (danmaku match, SSRF, progress ownership, i18n errors, paging, QueryClient)

This commit is contained in:
noelorin 2026-09-26 00:49:08 +08:00
parent e65acd1e9c
commit a7873891f2
16 changed files with 170 additions and 49 deletions

View File

@ -21,3 +21,6 @@ OPEN_DANMAKU_APP_SECRET=
# 可选:Bangumi API 地址(默认 https://api.bgm.tv) # 可选:Bangumi API 地址(默认 https://api.bgm.tv)
# BANGUMI_API_BASE=https://api.bgm.tv # BANGUMI_API_BASE=https://api.bgm.tv
# WebDAV 挂载威胁模型:仅允许 http(s);拒绝 localhost/127.*、169.254.*(云 metadata)。
# 局域网私网(192.168.* 等)允许,便于本机 OpenList/群晖;公网部署请自行加出站策略。

View File

@ -1,12 +1,11 @@
import type { Locale } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { isVideoFilename } from "@app/types";
import { Button } from "@app/ui"; import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; 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 { function pathOf(segments: string[]): string {
return segments.length > 0 ? `/${segments.join("/")}` : "/"; return segments.length > 0 ? `/${segments.join("/")}` : "/";
} }
@ -104,7 +103,9 @@ function BrowseInner(props: { locale: Locale }) {
{t(locale, "browse.scanDone", { count: scan.data.scanned })} {t(locale, "browse.scanDone", { count: scan.data.scanned })}
</p> </p>
)} )}
{scan.isError && <p className="text-sm text-red-600">{scan.error.message}</p>} {scan.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<button type="button" onClick={goRoot}> <button type="button" onClick={goRoot}>
{t(locale, "browse.root")} {t(locale, "browse.root")}
@ -134,7 +135,7 @@ function BrowseInner(props: { locale: Locale }) {
)} )}
<ul className="divide-y divide-border rounded-lg border border-border"> <ul className="divide-y divide-border rounded-lg border border-border">
{(dir.data?.entries ?? []).map((e) => { {(dir.data?.entries ?? []).map((e) => {
const isVideo = e.type === "file" && VIDEO_RE.test(e.basename); const isVideo = e.type === "file" && isVideoFilename(e.basename);
return ( return (
<li <li
key={e.filename} key={e.filename}
@ -182,9 +183,9 @@ function BrowseInner(props: { locale: Locale }) {
} }
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient();
export function BrowseBody(props: { locale: Locale }) { export function BrowseBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>

View File

@ -96,9 +96,8 @@ function Inner(props: { locale: Locale }) {
} }
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient();
export function DanmakuSettingsBody(props: { locale: Locale }) { export function DanmakuSettingsBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>

View File

@ -9,13 +9,18 @@ function LibraryInner(props: { locale: Locale }) {
const { locale } = props; const { locale } = props;
const [filter, setFilter] = useState<"" | "unmatched" | "ok">(""); const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [offset, setOffset] = useState(0);
const pageLimit = 24;
const list = trpc.media.libraryList.useQuery({ const list = trpc.media.libraryList.useQuery({
limit: 24, limit: pageLimit,
offset: 0, offset,
scrapeStatus: filter || undefined, scrapeStatus: filter || undefined,
q: q || undefined, q: q || undefined,
}); });
const progressList = list.data?.rows ?? []; const progressList = list.data?.rows ?? [];
const total = list.data?.total ?? 0;
const hasMore = offset + progressList.length < total;
const resetPaging = () => setOffset(0);
const [matchId, setMatchId] = useState<string | null>(null); const [matchId, setMatchId] = useState<string | null>(null);
const [searchQ, setSearchQ] = useState(""); const [searchQ, setSearchQ] = useState("");
const search = trpc.media.scrapeSearch.useQuery( const search = trpc.media.scrapeSearch.useQuery(
@ -38,12 +43,18 @@ function LibraryInner(props: { locale: Locale }) {
className="w-40" className="w-40"
placeholder={t(locale, "library.searchPlaceholder")} placeholder={t(locale, "library.searchPlaceholder")}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => {
setQ(e.target.value);
resetPaging();
}}
/> />
<select <select
className="h-9 rounded-md border border-border bg-background px-2 text-sm" className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)} onChange={(e) => {
setFilter(e.target.value as typeof filter);
resetPaging();
}}
> >
<option value="">{t(locale, "library.filterAll")}</option> <option value="">{t(locale, "library.filterAll")}</option>
<option value="ok">{t(locale, "library.filterOk")}</option> <option value="ok">{t(locale, "library.filterOk")}</option>
@ -96,12 +107,23 @@ function LibraryInner(props: { locale: Locale }) {
</div> </div>
</div> </div>
))} ))}
{progressList.length === 0 && ( {progressList.length === 0 && !list.isPending && (
<p className="col-span-full text-sm text-muted-foreground"> <p className="col-span-full text-sm text-muted-foreground">
{t(locale, "library.empty")} {t(locale, "library.empty")}
</p> </p>
)} )}
</div> </div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={list.isPending}
onClick={() => setOffset((o) => o + pageLimit)}
>
{t(locale, "library.loadMore")}
</Button>
</div>
)}
{matchId && ( {matchId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@ -144,9 +166,9 @@ function LibraryInner(props: { locale: Locale }) {
} }
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient();
export function LibraryBody(props: { locale: Locale }) { export function LibraryBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>

View File

@ -130,9 +130,8 @@ function MountsInner(props: { locale: Locale }) {
} }
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient();
export function MountsBody(props: { locale: Locale }) { export function MountsBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>

View File

@ -135,9 +135,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const input = snapshot(); const input = snapshot();
if (input) reportRef.current.mutate(input); if (input) reportRef.current.mutate(input);
}; };
let unloaded = false;
const flushUnload = () => { const flushUnload = () => {
if (unloaded) return;
const input = snapshot(); const input = snapshot();
if (!input) return; if (!input) return;
unloaded = true;
reportRef.current.mutate(input); reportRef.current.mutate(input);
sendProgressBeacon(input); sendProgressBeacon(input);
}; };
@ -321,9 +324,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
} }
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient();
export function WatchBody(props: { locale: Locale; mediaItemId: string }) { export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
const [qc] = useState(() => new QueryClient());
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>

View File

@ -75,7 +75,6 @@ export const mediaItemDao = {
const [row] = await db const [row] = await db
.update(mediaItems) .update(mediaItems)
.set({ .set({
mountId: data.mountId,
size: data.size, size: data.size,
mime: data.mime, mime: data.mime,
updatedAt: new Date(), updatedAt: new Date(),

View File

@ -47,6 +47,7 @@ export const en: Record<MessageKey, string> = {
"library.play": "Play", "library.play": "Play",
"library.match": "Match", "library.match": "Match",
"library.empty": "No media yet. Scan a mount in the file browser first.", "library.empty": "No media yet. Scan a mount in the file browser first.",
"library.loadMore": "Load more",
"library.matchTitle": "Manual Bangumi match", "library.matchTitle": "Manual Bangumi match",
"library.searchWork": "Search by title", "library.searchWork": "Search by title",
"library.bind": "Bind", "library.bind": "Bind",
@ -64,6 +65,7 @@ export const en: Record<MessageKey, string> = {
"browse.loadError": "Failed to read directory", "browse.loadError": "Failed to read directory",
"browse.playError": "Failed to add to library", "browse.playError": "Failed to add to library",
"browse.scanDone": "Scan complete: {count} files", "browse.scanDone": "Scan complete: {count} files",
"browse.scanError": "Scan failed. Check the mount configuration.",
"mounts.title": "Mounts", "mounts.title": "Mounts",
"mounts.add": "Add WebDAV", "mounts.add": "Add WebDAV",

View File

@ -48,6 +48,7 @@ export const zhCN = {
"library.play": "播放", "library.play": "播放",
"library.match": "匹配", "library.match": "匹配",
"library.empty": "暂无媒体,请先在文件浏览中扫描挂载。", "library.empty": "暂无媒体,请先在文件浏览中扫描挂载。",
"library.loadMore": "加载更多",
"library.matchTitle": "手动匹配 Bangumi", "library.matchTitle": "手动匹配 Bangumi",
"library.searchWork": "搜索作品名", "library.searchWork": "搜索作品名",
"library.bind": "绑定", "library.bind": "绑定",
@ -65,6 +66,7 @@ export const zhCN = {
"browse.loadError": "目录读取失败", "browse.loadError": "目录读取失败",
"browse.playError": "入库失败,请重试", "browse.playError": "入库失败,请重试",
"browse.scanDone": "扫描完成:{count} 个文件", "browse.scanDone": "扫描完成:{count} 个文件",
"browse.scanError": "扫描失败,请检查挂载配置",
"mounts.title": "挂载管理", "mounts.title": "挂载管理",
"mounts.add": "添加 WebDAV", "mounts.add": "添加 WebDAV",

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao"; import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
import type { DanmakuFetchOutput } from "@app/types"; import type { DanmakuFetchOutput } from "@app/types";
import { decryptSecret } from "./secret.js"; import { decryptSecret } from "./secret.js";
@ -148,7 +149,8 @@ async function fetchOpenNetworkXml(
}; };
if (matched.success === false) return null; if (matched.success === false) return null;
const episodeId = matched.matches?.[0]?.episodeId; 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 commentPath = `/api/v2/comment/${episodeId}`;
const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, { const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, {
@ -192,11 +194,13 @@ export const danmakuService = {
return { ok: true, source: "open-network", xml }; return { ok: true, source: "open-network", xml };
}, },
/** 本地 XML 由前端解析后直接喂播放器;此处仅确认媒体存在并记 size 日志约束 */ /** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> { async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) return Promise.reject(new Error("媒体不存在")); if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
if (byteSize > 20_000_000) return Promise.reject(new Error("XML 过大")); if (byteSize > 20_000_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
}
return { ok: true }; return { ok: true };
}, },
}; };

View File

@ -2,7 +2,13 @@ import { TRPCError } from "@trpc/server";
import { mountDao, type MountRow } from "@app/dao"; import { mountDao, type MountRow } from "@app/dao";
import type { MountCreateInput, MountListDirInput } from "@app/types"; import type { MountCreateInput, MountListDirInput } from "@app/types";
import { decryptSecret, encryptSecret } from "./secret.js"; 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 = { export type MountPublic = {
id: string; id: string;
@ -50,6 +56,14 @@ export const mountService = {
}, },
async create(userId: string, input: MountCreateInput): Promise<{ mount: MountPublic }> { 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 { try {
const row = await mountDao.create({ const row = await mountDao.create({
userId, userId,
@ -62,8 +76,17 @@ export const mountService = {
enabled: input.enabled, enabled: input.enabled,
}); });
return { mount: toPublic(row) }; return { mount: toPublic(row) };
} catch { } catch (e) {
throw new TRPCError({ code: "CONFLICT", message: "挂载名称已存在或创建失败" }); 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 }> { ): Promise<{ mount: MountPublic }> {
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (input.name !== undefined) patch["name"] = input.name; 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.username !== undefined) patch["username"] = input.username;
if (input.password !== undefined) { if (input.password !== undefined) {
patch["secretEnc"] = input.password ? encryptSecret(input.password) : ""; patch["secretEnc"] = input.password ? encryptSecret(input.password) : "";
@ -104,6 +137,11 @@ export const mountService = {
password?: string | undefined; password?: string | undefined;
rootPath: string; rootPath: string;
}): Promise<{ ok: boolean; message: string }> { }): Promise<{ ok: boolean; message: string }> {
try {
assertSafeWebdavUrl(input.baseUrl);
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : "地址不合法" };
}
try { try {
const client = createWebdav({ const client = createWebdav({
baseUrl: input.baseUrl, baseUrl: input.baseUrl,
@ -112,8 +150,8 @@ export const mountService = {
}); });
await listDirectory(client, input.rootPath || "/"); await listDirectory(client, input.rootPath || "/");
return { ok: true, message: "连接成功" }; return { ok: true, message: "连接成功" };
} catch (e) { } catch {
return { ok: false, message: e instanceof Error ? e.message : "连接失败" }; return { ok: false, message: "连接失败" };
} }
}, },
@ -131,10 +169,10 @@ export const mountService = {
return a.basename.localeCompare(b.basename); return a.basename.localeCompare(b.basename);
}); });
return { entries, path: input.path }; return { entries, path: input.path };
} catch (e) { } catch {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: e instanceof Error ? `读取目录失败: ${e.message}` : "读取目录失败", message: "读取目录失败",
}); });
} }
}, },

View File

@ -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"; import type { PlaybackReportInput } from "@app/types";
export const playbackService = { export const playbackService = {
async report(userId: string, input: PlaybackReportInput) { 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( const row = await playbackProgressDao.upsert(
userId, userId,
input.mediaItemId, input.mediaItemId,

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { joinWebdavPath, isVideoFilename } from "./webdav-client.js"; import { assertSafeWebdavUrl, isVideoFilename, joinWebdavPath } from "./webdav-client.js";
describe("webdav helpers", () => { describe("webdav helpers", () => {
it("joinWebdavPath", () => { it("joinWebdavPath", () => {
@ -11,3 +11,17 @@ describe("webdav helpers", () => {
expect(isVideoFilename("readme.txt")).toBe(false); 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();
});
});

View File

@ -1,23 +1,34 @@
import { createClient, type WebDAVClient } from "webdav"; import { createClient, type WebDAVClient } from "webdav";
import { isVideoFilename } from "@app/types";
const VIDEO_EXT = new Set([ export { isVideoFilename };
".mp4",
".mkv",
".webm",
".avi",
".mov",
".m4v",
".ts",
".flv",
".wmv",
".mpg",
".mpeg",
]);
export function isVideoFilename(name: string): boolean { /**
const i = name.lastIndexOf("."); * SSRF 基线:仅允许 http(s);拒绝 localhost/回环/链路本地(云 metadata)。
if (i < 0) return false; * 局域网私网段仍允许(自部署 WebDAV 常见);公网部署需额外网段策略。
return VIDEO_EXT.has(name.slice(i).toLowerCase()); */
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 { export function joinWebdavPath(root: string, rel: string): string {

View File

@ -1,3 +1,4 @@
/** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */ /** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */
export * from "./schemas.js"; export * from "./schemas.js";
export * from "./media-schemas.js"; export * from "./media-schemas.js";
export * from "./media-fs.js";

View File

@ -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);
}