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 地址(默认 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/群晖;公网部署请自行加出站策略。
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: "读取目录失败",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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