feat: 新增webdav

This commit is contained in:
2026-10-16 09:03:23 -07:00
parent 86c96b1a32
commit 94203e5746
15 changed files with 883 additions and 5 deletions
+15
View File
@@ -8,6 +8,21 @@ JACKETT_API_KEY=
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
ACCESS_TOKEN=
# 本地 pnpm dev / pnpm start 会同时启动只读 WebDAV,默认 3201;设为 0 关闭
# Docker 中配置 WEBDAV_PORT 后随主服务启动,留空或 0 则关闭
# WebDAV 密码复用 ACCESS_TOKEN;本地未配置任何密钥的演示模式使用 mock
WEBDAV_PORT=
# 默认仅监听本机;Docker bridge 网络需要对外连接时设为 0.0.0.0,并映射端口
WEBDAV_HOST=
# 用户名,默认 torbox
WEBDAV_USERNAME=
# 挂载路径,默认 /;通过同一域名反代 /webdav/ 时设置为 /webdav
WEBDAV_BASE_PATH=
# 面板中可复制的对外地址;使用反向代理时配置,如 https://你的域名/webdav/
# 本地留空即可,面板自动生成 http://localhost:3201/;不在地址里放账号或密码
WEBDAV_PUBLIC_URL=
# 现有管理页的内部地址,本地默认 http://127.0.0.1:3200,Docker 默认跟随 PORT;可留空
WEBDAV_APP_URL=
# 日志级别:fatal / error / warn / info / debug / trace / silent
# 不配置时开发环境为 debug,生产环境为 info
LOG_LEVEL=
+8 -2
View File
@@ -30,17 +30,23 @@ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
# 数据库迁移文件:启动时自动应用(src/server/db/migrate.ts)
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
# WebDAV 独立进程复用同一容器内的 Next API
COPY --from=builder --chown=node:node /app/src/server/webdav ./src/server/webdav
COPY --from=builder --chown=node:node /app/scripts ./scripts
# Next 不追踪独立脚本的依赖;仅复制 XML 解析器的自包含 CJS 构建与入口描述
COPY --from=builder --chown=node:node /app/node_modules/fast-xml-parser/lib ./node_modules/fast-xml-parser/lib
COPY --from=builder --chown=node:node /app/node_modules/fast-xml-parser/package.json ./node_modules/fast-xml-parser/package.json
# SQLite 数据库目录:生产环境通过 DB_FILE_NAME 指向挂载卷;预先创建并授权,未挂载时也能启动
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 22787
EXPOSE 22787 3201
# 端口可由 -e PORT 覆盖,检查必须跟随实际端口(host 网络下写死会探测到别的服务)
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://127.0.0.1:${PORT}/login || exit 1
# 配置由部署时挂载的 /app/.env 提供(不存在则忽略);已通过 -e 传入的变量优先,不会被覆盖
CMD ["node", "--env-file-if-exists=/app/.env", "server.js"]
CMD ["node", "--env-file-if-exists=/app/.env", "scripts/start.mjs"]
+47
View File
@@ -6,3 +6,50 @@
pnpm install
pnpm dev # http://localhost:3200
```
## 只读 WebDAV
WebDAV 是独立的 HTTP 服务,通过管理页已有的 API 提供文件访问。支持目录浏览、
文件属性、下载和 HTTP Range;上传、删除、重命名等写操作返回 405。
本地在 `.env.local` 中配置 `ACCESS_TOKEN` 后,`pnpm dev` 和 `pnpm start` 都会同时
启动管理页和 WebDAV,停止项目时一起退出,无需另开终端。默认端口分别为 3200 和 3201。
设置 `WEBDAV_PORT=0` 可关闭 WebDAV;管理页端口可通过 `PORT` 调整。
登录面板后,点击顶栏的「WebDAV」即可分别复制连接地址、用户名和密码。默认连接信息:
- 地址:`http://127.0.0.1:3201/`
- 用户名:`torbox`(可通过 `WEBDAV_USERNAME` 修改)
- 密码:`.env.local` 中的 `ACCESS_TOKEN`
目录结构为 `/来源/资源名 [资源ID]/原文件路径`,来源包含 `torrents`、`usenet`、
`webdl`。保留原文件的子目录,重名文件加文件 ID 避免覆盖;只显示下载完成、仍可用且
未被隐藏的文件。目录缓存 30 秒,隐藏后的旧文件链接由现有播放接口立即拒绝。
文件内容通过现有 `/api/stream` 代理,沿用直链缓存、限流和图片缓存。
资源列表获取失败时返回错误,不将不完整列表缓存成完整目录。
支持 `PROPFIND` 的 Depth 0 和 1、`allprop`、`propname` 和指定属性查询;
无限深度的目录查询返回 403。演示模式可以浏览目录和读取模拟图片,真实视频需要
配置 `TORBOX_API_KEY`。本地未配置任何密钥时,演示模式的登录和 WebDAV 密码均为 `mock`;
使用真实 TorBox 资源时必须显式配置 `ACCESS_TOKEN`。
Docker 中在环境变量或挂载的 `/app/.env` 中配置 `WEBDAV_PORT=3201`,重建镜像后会
随管理页启动;留空或设为 0 时仅启动管理页。host 网络可保留默认监听地址 `127.0.0.1`,
bridge 网络对外提供服务时设置 `WEBDAV_HOST=0.0.0.0` 并映射 `3201:3201`。
`WEBDAV_APP_URL` 默认指向同容器的管理页端口;管理页运行在其他地址时再配置此项。
公网访问请通过 HTTPS 反向代理,并设置 `WEBDAV_PUBLIC_URL` 为客户端实际访问的地址,
面板会优先显示这个地址。只监听本机且未配置对外地址时,远程访问面板不会显示不可用的
本机连接地址。需要共用现有域名时,将 `WEBDAV_BASE_PATH` 设置为 `/webdav`,
`WEBDAV_PUBLIC_URL` 设置为 `https://你的域名/webdav/`,保留此前缀转发,例如 Nginx:
```nginx
location /webdav/ {
proxy_pass http://127.0.0.1:3201;
proxy_buffering off;
proxy_read_timeout 3600s;
}
```
此时客户端地址为 `https://你的域名/webdav/`。不要将 WebDAV 路径转发到 Next.js
的页面端口:App Router 不处理 WebDAV 所需的 `PROPFIND` 方法。
+3 -2
View File
@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3200",
"dev": "node scripts/start.mjs dev",
"build": "next build",
"start": "next start -p 3200",
"start": "node scripts/start.mjs start",
"webdav": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/webdav.mjs",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check .",
+63
View File
@@ -0,0 +1,63 @@
// 统一启动入口:本地运行 Next CLI,Docker 保留 standalone,两者共管 WebDAV 生命周期。
import { execFile, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { getWebdavConfig } from "../src/server/webdav/config.mjs";
const mode = process.argv[2] || "standalone";
if (!["dev", "start", "standalone"].includes(mode))
throw new Error("启动模式必须为 dev、start 或 standalone");
const local = mode !== "standalone";
const load = createRequire(import.meta.url);
if (local) {
process.env.NODE_ENV = mode === "dev" ? "development" : "production";
// 复用当前 Next 安装中的 env loader,与 Next 的 .env* 优先级和变量展开保持一致。
const loadNext = createRequire(load.resolve("next/package.json"));
loadNext("@next/env").loadEnvConfig(process.cwd(), mode === "dev");
process.env.PORT = process.env.PORT?.trim() || "3200";
process.env.WEBDAV_PORT = process.env.WEBDAV_PORT?.trim() || "3201";
}
const config = getWebdavConfig();
if (config.enabled && config.port === Number(process.env.PORT))
throw new Error("WebDAV 与管理页不能使用相同端口");
const children = [];
let stopping = false;
const stop = (code = 0) => {
if (stopping) return;
stopping = true;
process.exitCode = code;
for (const child of children) {
if (child.exitCode !== null || child.signalCode !== null) continue;
// next dev 还会创建服务进程;Windows 必须结束整个子进程树,避免残留监听端口。
if (process.platform === "win32")
execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"], () => {});
else child.kill("SIGTERM");
}
setTimeout(() => {
for (const child of children) child.kill("SIGKILL");
}, 6000).unref();
};
const start = (file, args = [], env = process.env) => {
const child = spawn(process.execPath, [file, ...args], {
stdio: "inherit",
env
});
children.push(child);
child.on("error", () => stop(1));
child.on("exit", (code, signal) => {
if (!stopping) stop(code || (signal ? 1 : 0));
});
};
if (local)
start(load.resolve("next/dist/bin/next"), [mode, "-p", process.env.PORT]);
else start("server.js");
if (config.enabled) {
const env = { ...process.env };
// 本地无密钥的演示模式沿用网页的 mock 密钥,不改变 Next 进程的演示提示。
if (local && !env.TORBOX_API_KEY?.trim() && !env.ACCESS_TOKEN?.trim())
env.ACCESS_TOKEN = "mock";
start("scripts/webdav.mjs", [], env);
}
process.once("SIGINT", () => stop());
process.once("SIGTERM", () => stop());
+30
View File
@@ -0,0 +1,30 @@
import { createWebDavServer } from "../src/server/webdav/server.mjs";
import { getWebdavConfig } from "../src/server/webdav/config.mjs";
process.env.WEBDAV_PORT ||= "3201";
const config = getWebdavConfig();
if (!config.enabled) process.exit(0);
const server = createWebDavServer({
appUrl: config.appUrl,
username: config.username,
password: process.env.ACCESS_TOKEN,
basePath: config.basePath
});
server.headersTimeout = 10_000;
server.requestTimeout = 30_000;
server.on("error", () => {
console.error("WebDAV 启动失败,请检查监听地址和端口是否被占用");
process.exitCode = 1;
});
server.listen(config.port, config.host, () => {
console.info(`WebDAV 已启动:端口 ${config.port},仅提供只读文件访问`);
});
const close = () => {
server.close();
// 先允许正在进行的传输结束,容器停止时最多等待 5 秒。
setTimeout(() => server.closeAllConnections(), 5000).unref();
};
process.once("SIGINT", close);
process.once("SIGTERM", close);
+10 -1
View File
@@ -1,12 +1,14 @@
// 应用顶栏
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import type { WebdavConnectionInfo } from "@/types/webdav";
import { AddResourceButton } from "../add-resource/add-resource-button";
import { HeaderBrand } from "./header-brand";
import { LogoutButton } from "./logout-button";
import { MobileMenu } from "./mobile-menu";
import { PlaylistMenu } from "./playlist-menu";
import { WebdavDialog } from "./webdav-dialog";
type AppHeaderProps = {
token: string;
@@ -14,9 +16,15 @@ type AppHeaderProps = {
mock: boolean;
/** 已配置 Jackett(或演示模式);否则添加资源弹窗里没有搜索页 */
searchEnabled: boolean;
webdav: WebdavConnectionInfo;
};
export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
export const AppHeader = ({
token,
mock,
searchEnabled,
webdav
}: AppHeaderProps) => (
<header className="shrink-0 border-b">
<div className="mx-auto flex h-14 w-full sm:max-w-[1920px] items-center gap-3 px-4 md:px-6">
<HeaderBrand />
@@ -32,6 +40,7 @@ export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
)}
<div className="ml-auto flex items-center gap-1">
<AddResourceButton searchEnabled={searchEnabled} />
<WebdavDialog token={token} connection={webdav} />
{/* 手机宽度下播放列表与退出登录收进右上角菜单 */}
<div className="hidden md:contents">
<PlaylistMenu token={token} />
@@ -0,0 +1,157 @@
"use client";
import { useId, useState } from "react";
import { CopyIcon, HardDriveIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useCopy } from "@/hooks/use-copy";
import { useOrigin } from "@/hooks/use-origin";
import type { WebdavConnectionInfo } from "@/types/webdav";
const CopyField = ({
label,
value,
secret = false,
placeholder
}: {
label: string;
value: string;
secret?: boolean;
placeholder?: string;
}) => {
const id = useId();
const copy = useCopy();
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<InputGroup>
<InputGroupInput
id={id}
value={value}
readOnly
type={secret ? "password" : "text"}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`复制${label}`}
disabled={!value}
onClick={() => copy(value, `已复制${label}`)}
>
<CopyIcon data-icon="inline-start" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{secret && (
<FieldDescription>与网页登录使用同一访问密钥。</FieldDescription>
)}
</Field>
);
};
export const WebdavDialog = ({
token,
connection
}: {
token: string;
connection: WebdavConnectionInfo;
}) => {
const [open, setOpen] = useState(false);
const origin = useOrigin();
useCloseOnBack(open, () => setOpen(false));
let url = connection.publicUrl || "";
let needsPublicUrl = false;
if (!url && origin) {
const address = new URL(origin);
const local = /^(localhost|127(?:\.\d+){3}|\[::1\])$/i.test(
address.hostname
);
needsPublicUrl = connection.localOnly && !local;
if (!needsPublicUrl) {
address.protocol = "http:";
address.port = String(connection.port);
address.pathname = connection.basePath.replace(/\/?$/, "/");
url = address.toString();
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
variant="ghost"
aria-label="WebDAV 连接"
title="WebDAV 连接"
/>
}
>
<HardDriveIcon data-icon="inline-start" />
<span className="hidden sm:inline">WebDAV</span>
</DialogTrigger>
<DialogContent className="flex flex-col gap-5">
<DialogHeader>
<DialogTitle>WebDAV 连接</DialogTitle>
<DialogDescription>
在支持 WebDAV 的播放器或文件客户端中连接,文件仅供读取。
</DialogDescription>
</DialogHeader>
{connection.enabled ? (
<>
<FieldGroup>
<CopyField
label="连接地址"
value={url}
placeholder={
needsPublicUrl ? "尚未配置对外连接地址" : "正在获取地址"
}
/>
<CopyField label="用户名" value={connection.username} />
<CopyField label="密码" value={token} secret />
</FieldGroup>
{needsPublicUrl && (
<Alert>
<AlertTitle>当前仅允许服务器本机连接</AlertTitle>
<AlertDescription>
请配置对外可访问的 WebDAV 地址后,再从这里复制连接地址。
</AlertDescription>
</Alert>
)}
</>
) : (
<Alert>
<AlertTitle>WebDAV 未启用</AlertTitle>
<AlertDescription>
启用服务并重启项目后,可在这里复制连接信息。
</AlertDescription>
</Alert>
)}
</DialogContent>
</Dialog>
);
};
+2
View File
@@ -2,6 +2,7 @@
import { isMockMode } from "@/server/env";
import { isSearchAvailable } from "@/server/search";
import { requireSessionToken } from "@/server/session";
import { getWebdavConnectionInfo } from "@/server/webdav";
import { AppHeader } from "./components/header/app-header";
@@ -15,6 +16,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
token={token}
mock={isMockMode()}
searchEnabled={isSearchAvailable()}
webdav={getWebdavConnectionInfo()}
/>
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
</div>
+15
View File
@@ -0,0 +1,15 @@
import { getWebdavConfig } from "@/server/webdav/config.mjs";
import type { WebdavConnectionInfo } from "@/types/webdav";
/** 只下发连接所需字段,内部服务地址留在服务端。 */
export const getWebdavConnectionInfo = (): WebdavConnectionInfo => {
const config = getWebdavConfig();
return {
enabled: config.enabled,
port: config.port,
username: config.username,
basePath: config.basePath,
publicUrl: config.publicUrl,
localOnly: /^(localhost|127(?:\.\d+){3}|::1)$/i.test(config.host)
};
};
+39
View File
@@ -0,0 +1,39 @@
// 启动脚本和页面共用连接配置,避免展示的端口、路径与实际服务不一致。
import { decodePath } from "./tree.mjs";
export const getWebdavConfig = () => {
const configuredPort = process.env.WEBDAV_PORT?.trim();
const enabled = Boolean(configuredPort && configuredPort !== "0");
const port = enabled ? Number(configuredPort) : 3201;
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("WEBDAV_PORT 必须为 1–65535,或设为 0 关闭");
let publicUrl = process.env.WEBDAV_PUBLIC_URL?.trim() || null;
if (publicUrl) {
const url = new URL(publicUrl);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
throw new Error(
"WEBDAV_PUBLIC_URL 必须是不含账号和查询参数的 HTTP(S) 地址"
);
url.pathname = url.pathname.replace(/\/?$/, "/");
publicUrl = url.toString();
}
return {
enabled,
port,
host: process.env.WEBDAV_HOST?.trim() || "127.0.0.1",
username: process.env.WEBDAV_USERNAME?.trim() || "torbox",
basePath: decodePath(process.env.WEBDAV_BASE_PATH?.trim() || "/"),
publicUrl,
appUrl:
process.env.WEBDAV_APP_URL?.trim() ||
`http://127.0.0.1:${process.env.PORT || 3200}`
};
};
+186
View File
@@ -0,0 +1,186 @@
import { createRequire } from "node:module";
import { hrefFor } from "./tree.mjs";
// 使用包内自包含的 CJS 构建,独立进程在 standalone 镜像中无需额外依赖树。
const load = createRequire(import.meta.url);
const { XMLParser, XMLValidator } = load("fast-xml-parser");
export const METHODS = "OPTIONS, PROPFIND, GET, HEAD";
const XML_HEADER = '<?xml version="1.0" encoding="utf-8"?>';
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: false
});
export class HttpError extends Error {
constructor(status, message, headers = {}) {
super(message);
this.status = status;
this.headers = headers;
}
}
export const escapeXml = (value) =>
String(value).replace(/[&<>"']/g, (char) => {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&apos;"
}[char];
});
const namespaces = (node, inherited = {}) => {
const result = { ...inherited };
for (const [key, value] of Object.entries(node || {})) {
if (key === "@_xmlns") result[""] = value;
else if (key.startsWith("@_xmlns:")) result[key.slice(8)] = value;
}
return result;
};
const property = (tag, scope) => {
const at = tag.indexOf(":");
const prefix = at < 0 ? "" : tag.slice(0, at);
if (prefix && !scope[prefix])
throw new HttpError(400, "Undeclared XML namespace");
return { name: tag.slice(at + 1), namespace: scope[prefix] || "" };
};
const tags = (node) =>
node && typeof node === "object"
? Object.keys(node).filter((key) => !/^[#?@]/.test(key))
: [];
/** 按 namespace URI 识别 DAV 元素,未知属性以原命名空间返回 404。 */
const parseQuery = (body) => {
if (!body.trim()) return { mode: "allprop", properties: [] };
if (/<!DOCTYPE|<!ENTITY/i.test(body) || XMLValidator.validate(body) !== true)
throw new HttpError(400, "Invalid XML");
const document = parser.parse(body);
const roots = tags(document);
if (roots.length !== 1) throw new HttpError(400, "Expected one XML root");
const rootTag = roots[0];
const root = document[rootTag];
if (Array.isArray(root)) throw new HttpError(400, "Expected one XML root");
const scope = namespaces(root);
const rootName = property(rootTag, scope);
if (rootName.namespace !== "DAV:" || rootName.name !== "propfind")
throw new HttpError(400, "Expected DAV:propfind");
const children = tags(root).map((tag) => ({
tag,
...property(tag, namespaces(root[tag], scope))
}));
const modes = children.filter(
({ name, namespace }) =>
namespace === "DAV:" && ["allprop", "propname", "prop"].includes(name)
);
if (modes.length !== 1 || Array.isArray(root[modes[0].tag]))
throw new HttpError(400, "Expected one PROPFIND mode");
const mode = modes[0].name;
const container =
mode === "prop"
? modes[0]
: children.find(
({ name, namespace }) => name === "include" && namespace === "DAV:"
);
const node = container ? root[container.tag] : {};
const childScope = namespaces(node, scope);
const properties = tags(node).map((tag) =>
property(tag, namespaces(node[tag], childScope))
);
return { mode, properties };
};
export const readPropfind = async (req) => {
const chunks = [];
let size = 0;
// 超限后继续排空请求,保证客户端能读到 413,且不再保留正文。
for await (const chunk of req) {
size += chunk.length;
if (size <= 64 * 1024) chunks.push(chunk);
}
if (size > 64 * 1024) throw new HttpError(413, "XML body too large");
return parseQuery(Buffer.concat(chunks).toString("utf8"));
};
const valuesFor = (entry) => {
const values = {
displayname: escapeXml(entry.name),
resourcetype: entry.children ? "<D:collection/>" : "",
getcontenttype: escapeXml(
entry.children ? "httpd/unix-directory" : entry.contentType
),
supportedlock: "",
lockdiscovery: ""
};
if (entry.children || Number.isFinite(entry.size))
values.getcontentlength = String(entry.children ? 0 : entry.size);
const date = entry.createdAt ? new Date(entry.createdAt) : null;
if (date && Number.isFinite(date.getTime())) {
values.creationdate = date.toISOString();
values.getlastmodified = date.toUTCString();
}
return values;
};
const responseFor = (entry, query) => {
const values = valuesFor(entry);
const requested =
query.mode === "prop"
? query.properties
: [
...Object.keys(values).map((name) => ({ namespace: "DAV:", name })),
...query.properties
];
const found = [];
const missing = [];
const seen = new Set();
for (const { namespace, name } of requested) {
const key = `${namespace}\0${name}`;
if (seen.has(key)) continue;
seen.add(key);
const known = namespace === "DAV:" && Object.hasOwn(values, name);
const tag = namespace ? `P:${name}` : name;
const declaration = namespace
? ` xmlns:P="${escapeXml(namespace)}"`
: ' xmlns=""';
const xml = `<${tag}${declaration}>${
known && query.mode !== "propname" ? values[name] : ""
}</${tag}>`;
(known ? found : missing).push(xml);
}
const propstat = (properties, status) =>
properties.length
? `<D:propstat><D:prop>${properties.join("")}</D:prop><D:status>HTTP/1.1 ${status}</D:status></D:propstat>`
: "";
return `<D:response><D:href>${escapeXml(hrefFor(entry))}</D:href>${
propstat(found, "200 OK") + propstat(missing, "404 Not Found")
}</D:response>`;
};
export const multistatus = (entry, depth, query) => {
if (!["0", "1", "infinity"].includes(depth))
throw new HttpError(400, "Invalid Depth");
if (entry.children && depth === "infinity")
throw new HttpError(
403,
`${XML_HEADER}<D:error xmlns:D="DAV:"><D:propfind-finite-depth/></D:error>`,
{ "Content-Type": "application/xml; charset=utf-8" }
);
const entries = [entry];
if (entry.children && depth === "1") entries.push(...entry.children.values());
return `${XML_HEADER}<D:multistatus xmlns:D="DAV:">${entries
.map((node) => responseFor(node, query))
.join("")}</D:multistatus>`;
};
export const send = (res, status, body = "", headers = {}) => {
res.writeHead(status, {
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store",
...headers
});
res.end(body);
};
+208
View File
@@ -0,0 +1,208 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
HttpError,
METHODS,
multistatus,
readPropfind,
send
} from "./protocol.mjs";
import { buildTree, decodePath } from "./tree.mjs";
const CACHE_MS = 30_000;
const LOCK_MS = 15 * 60_000;
const RESPONSE_HEADERS = [
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag",
"retry-after"
];
const hash = (value) => createHash("sha256").update(value).digest();
/** 独立 HTTP 入口通过固定的内部 API 地址复用业务逻辑,不接受客户端指定上游。 */
export const createWebDavServer = ({
appUrl,
username,
password,
basePath
}) => {
const origin = new URL(appUrl);
if (
!["http:", "https:"].includes(origin.protocol) ||
origin.username ||
origin.password ||
origin.pathname !== "/" ||
origin.search ||
origin.hash
)
throw new Error("WEBDAV_APP_URL 必须是 HTTP(S) 根地址");
if (!username || username.includes(":") || !password?.trim())
throw new Error("WebDAV 需要有效的 WEBDAV_USERNAME 和 ACCESS_TOKEN");
const expected = hash(`${username}:${password}`);
const failures = new Map();
let cache;
let pending;
const library = async () => {
if (cache && cache.expiresAt > Date.now()) return cache.entries;
if (!pending) {
pending = (async () => {
const response = await fetch(new URL("/api/library", origin), {
headers: { Authorization: `Bearer ${password}` },
signal: AbortSignal.timeout(60_000),
redirect: "error"
});
if (!response.ok)
throw new HttpError(
response.status === 429 ? 429 : 502,
"Library request failed",
response.headers.has("retry-after")
? { "Retry-After": response.headers.get("retry-after") }
: {}
);
const { code, data } = await response.json();
if (code !== 0 || !Array.isArray(data?.items))
throw new HttpError(502, "Invalid library response");
if (Object.keys(data.errors || {}).length)
throw new HttpError(503, "Library is temporarily incomplete", {
"Retry-After": String(
Math.max(1, Math.ceil((data.retryAfterMs || 30_000) / 1000))
)
});
const entries = buildTree(data.items, basePath);
cache = { entries, expiresAt: Date.now() + CACHE_MS };
return entries;
})().finally(() => {
pending = undefined;
});
}
return pending;
};
const authorize = (req) => {
const now = Date.now();
for (const [key, value] of failures)
if (value.until <= now) failures.delete(key);
const key = req.socket.remoteAddress || "unknown";
const failed = failures.get(key);
if (failed?.count >= 5)
throw new HttpError(429, "Too many authentication attempts", {
"Retry-After": String(Math.ceil((failed.until - now) / 1000))
});
const header = req.headers.authorization || "";
const match = /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);
if (match) {
const presented = Buffer.from(match[1], "base64").toString("utf8");
if (timingSafeEqual(hash(presented), expected)) {
failures.delete(key);
return;
}
}
if (header)
failures.set(key, {
count: (failed?.count || 0) + 1,
until: failed?.until || now + LOCK_MS
});
throw new HttpError(401, "Authentication required", {
"WWW-Authenticate": 'Basic realm="TorBox WebDAV", charset="UTF-8"'
});
};
const stream = async (req, res, entry) => {
const abort = new AbortController();
const onClose = () => abort.abort();
res.on("close", onClose);
// 只限制等待响应头的时间,大文件传输不设总时长限制。
const timeout = setTimeout(() => abort.abort(), 60_000);
try {
const headers = {
Authorization: `Bearer ${password}`,
"Accept-Encoding": "identity"
};
for (const name of [
"range",
"if-range",
"if-none-match",
"if-modified-since"
])
if (req.headers[name]) headers[name] = req.headers[name];
const upstream = await fetch(new URL(entry.streamPath, origin), {
method: req.method,
headers,
signal: abort.signal,
redirect: "error"
});
clearTimeout(timeout);
res.statusCode = upstream.status;
res.setHeader("Cache-Control", "no-store");
for (const name of RESPONSE_HEADERS)
if (upstream.headers.has(name))
res.setHeader(name, upstream.headers.get(name));
if (upstream.body && req.method !== "HEAD")
await pipeline(Readable.fromWeb(upstream.body), res);
else res.end();
} finally {
clearTimeout(timeout);
res.off("close", onClose);
abort.abort();
}
};
return createServer(async (req, res) => {
try {
authorize(req);
let path;
try {
path = decodePath(req.url || "/");
} catch {
throw new HttpError(400, "Invalid path");
}
if (
path !== basePath &&
!path.startsWith(basePath === "/" ? "/" : `${basePath}/`)
)
throw new HttpError(404, "Not found");
res.setHeader("DAV", "1");
res.setHeader("Allow", METHODS);
if (req.method === "OPTIONS") return send(res, 200);
if (!["PROPFIND", "GET", "HEAD"].includes(req.method))
throw new HttpError(405, "Read-only WebDAV");
const query = req.method === "PROPFIND" ? await readPropfind(req) : null;
const entry = (await library()).get(path);
if (res.destroyed) return;
if (!entry) throw new HttpError(404, "Not found");
if (!entry.children && (req.url || "").split("?")[0].endsWith("/"))
throw new HttpError(404, "Not a directory");
if (query)
return send(
res,
207,
multistatus(entry, req.headers.depth || "infinity", query),
{
"Content-Type": "application/xml; charset=utf-8"
}
);
if (!entry.children) return await stream(req, res, entry);
// 目录内容通过 PROPFIND 获取,GET/HEAD 的长度与目录属性中的 0 保持一致。
return send(res, 200, "", {
"Content-Type": "httpd/unix-directory"
});
} catch (error) {
if (res.destroyed) return;
if (res.headersSent) return res.destroy();
send(
res,
error instanceof HttpError ? error.status : 502,
error instanceof HttpError
? error.message
: "WebDAV upstream unavailable",
error instanceof HttpError ? error.headers : {}
);
}
});
};
+91
View File
@@ -0,0 +1,91 @@
// 虚拟目录只保存文件引用,文件内容仍由现有 /api/stream 提供。
const SOURCES = ["torrents", "usenet", "webdl"];
const cleanName = (value) => {
const name = String(value)
.toWellFormed()
.replace(/[\p{Cc}\uFFFE\uFFFF<>:"|?*\\/]/gu, "_")
.replace(/[. ]+$/, "");
return !name || name === "." || name === ".." ? "_" : name;
};
/** 逐段解码,拒绝路径穿越和编码后的分隔符;不经过 URL 的路径自动归一化。 */
export const decodePath = (value) => {
if (!value.startsWith("/")) throw new Error("invalid path");
const parts = value.split("?")[0].split("/").filter(Boolean);
const decoded = parts.map((part) => {
const name = decodeURIComponent(part);
if (name === "." || name === ".." || /[\p{Cc}\\/]/u.test(name))
throw new Error("invalid path");
return name;
});
return `/${decoded.join("/")}`;
};
export const hrefFor = (entry) =>
entry.path.split("/").map(encodeURIComponent).join("/") +
(entry.children && entry.path !== "/" ? "/" : "");
export const buildTree = (items, basePath) => {
const entries = new Map();
const directory = (path, name, createdAt) => {
if (!entries.has(path))
entries.set(path, { path, name, createdAt, children: new Map() });
return entries.get(path);
};
const childDirectory = (parent, name, createdAt) => {
const path = `${parent.path === "/" ? "" : parent.path}/${name}`;
const child = directory(path, name, createdAt);
parent.children.set(name, child);
return child;
};
const root = directory(basePath, "TorBox", null);
for (const source of SOURCES) childDirectory(root, source, null);
for (const item of items) {
if (!SOURCES.includes(item.source) || item.status !== "ready") continue;
const visible = item.files.filter((file) => !file.hidden);
if (!visible.length) continue;
const parent = childDirectory(
root.children.get(item.source),
`${cleanName(item.name)} [${item.id}]`,
item.createdAt
);
// 先建立目录,再加入文件,避免同名文件与目录互相覆盖。
const files = visible
.map((file) => {
const parts = file.name.split(/[\\/]/).filter(Boolean);
if (parts.length > 1 && parts[0] === item.name) parts.shift();
const names = (parts.length ? parts : [file.short_name || file.id]).map(
cleanName
);
let folder = parent;
for (const name of names.slice(0, -1))
folder = childDirectory(folder, name, item.createdAt);
return { file, folder, name: names.at(-1) };
})
.sort((a, b) => a.file.id - b.file.id);
for (const { file, folder, name: original } of files) {
let name = original;
let attempt = 0;
while (folder.children.has(name)) {
const dot = original.lastIndexOf(".");
const at = dot > 0 ? dot : original.length;
const suffix = attempt++ ? `${file.id}-${attempt}` : String(file.id);
name = `${original.slice(0, at)} [${suffix}]${original.slice(at)}`;
}
const path = `${folder.path}/${name}`;
const entry = {
path,
name,
createdAt: item.createdAt,
size: file.size,
contentType: file.mimetype || "application/octet-stream",
streamPath: `/api/stream/${item.source}/${item.id}/${file.id}/${encodeURIComponent(name)}`
};
entries.set(path, entry);
folder.children.set(name, entry);
}
}
return entries;
};
+9
View File
@@ -0,0 +1,9 @@
/** 登录后提供给连接面板的信息;不包含 TorBox 密钥或内部 API 地址。 */
export type WebdavConnectionInfo = {
enabled: boolean;
port: number;
username: string;
basePath: string;
publicUrl: string | null;
localOnly: boolean;
};