feat: 新增webdav
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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 .",
|
||||
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
@@ -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}`
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[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);
|
||||
};
|
||||
@@ -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 : {}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 登录后提供给连接面板的信息;不包含 TorBox 密钥或内部 API 地址。 */
|
||||
export type WebdavConnectionInfo = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
username: string;
|
||||
basePath: string;
|
||||
publicUrl: string | null;
|
||||
localOnly: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user