fix: caveman-review findings (danmaku match, SSRF, progress ownership, i18n errors, paging, QueryClient)
This commit is contained in:
parent
e65acd1e9c
commit
a7873891f2
|
|
@ -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/群晖;公网部署请自行加出站策略。
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
</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">
|
||||
<button type="button" onClick={goRoot}>
|
||||
{t(locale, "browse.root")}
|
||||
|
|
@ -134,7 +135,7 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
)}
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{(dir.data?.entries ?? []).map((e) => {
|
||||
const isVideo = e.type === "file" && VIDEO_RE.test(e.basename);
|
||||
const isVideo = e.type === "file" && isVideoFilename(e.basename);
|
||||
return (
|
||||
<li
|
||||
key={e.filename}
|
||||
|
|
@ -182,9 +183,9 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
}
|
||||
|
||||
const client = createTRPCReactClient();
|
||||
const qc = new QueryClient();
|
||||
|
||||
export function BrowseBody(props: { locale: Locale }) {
|
||||
const [qc] = useState(() => new QueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
|
|
|
|||
|
|
@ -96,9 +96,8 @@ function Inner(props: { locale: Locale }) {
|
|||
}
|
||||
|
||||
const client = createTRPCReactClient();
|
||||
const qc = new QueryClient();
|
||||
|
||||
export function DanmakuSettingsBody(props: { locale: Locale }) {
|
||||
const [qc] = useState(() => new QueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ function LibraryInner(props: { locale: Locale }) {
|
|||
const { locale } = props;
|
||||
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const pageLimit = 24;
|
||||
const list = trpc.media.libraryList.useQuery({
|
||||
limit: 24,
|
||||
offset: 0,
|
||||
limit: pageLimit,
|
||||
offset,
|
||||
scrapeStatus: filter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
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 [searchQ, setSearchQ] = useState("");
|
||||
const search = trpc.media.scrapeSearch.useQuery(
|
||||
|
|
@ -38,12 +43,18 @@ function LibraryInner(props: { locale: Locale }) {
|
|||
className="w-40"
|
||||
placeholder={t(locale, "library.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
resetPaging();
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
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="ok">{t(locale, "library.filterOk")}</option>
|
||||
|
|
@ -96,12 +107,23 @@ function LibraryInner(props: { locale: Locale }) {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{progressList.length === 0 && (
|
||||
{progressList.length === 0 && !list.isPending && (
|
||||
<p className="col-span-full text-sm text-muted-foreground">
|
||||
{t(locale, "library.empty")}
|
||||
</p>
|
||||
)}
|
||||
</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 && (
|
||||
<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 qc = new QueryClient();
|
||||
|
||||
export function LibraryBody(props: { locale: Locale }) {
|
||||
const [qc] = useState(() => new QueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const en: Record<MessageKey, string> = {
|
|||
"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<MessageKey, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
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: "读取目录失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/** `@app/types` 入口:统一导出 Zod 入参 schema 与跨层共享 DTO。 */
|
||||
export * from "./schemas.js";
|
||||
export * from "./media-schemas.js";
|
||||
export * from "./media-fs.js";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue