diff --git a/src/backend/internal/driver/proxy.ts b/src/backend/internal/driver/proxy.ts index e45fc6ef..3f05539c 100644 --- a/src/backend/internal/driver/proxy.ts +++ b/src/backend/internal/driver/proxy.ts @@ -263,7 +263,9 @@ export function getDownProxyUrl(storage: any): string { // 1) 顶层字段(正常路径) for (const key of ["down_proxy_url", "downProxyUrl"]) { const val = storage[key] - if (typeof val === "string" && val.trim()) return val.trim() + // 对齐 Go GenerateDownProxyURL:多行只取第一行 + //(strings.Split(storage.DownProxyURL, "\n")[0]),否则会把换行带进 URL。 + if (typeof val === "string" && val.trim()) return val.split("\n")[0].trim() } // 2) 回退到 addition 内的别名(兼容历史写法) @@ -278,7 +280,7 @@ export function getDownProxyUrl(storage: any): string { if (!addition || typeof addition !== "object") return "" for (const key of PROXY_URL_ADDITION_KEYS) { const val = addition[key] - if (typeof val === "string" && val.trim()) return val.trim() + if (typeof val === "string" && val.trim()) return val.split("\n")[0].trim() } return "" } diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 39c784ad..09083e31 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -356,7 +356,9 @@ export const defaultDb = { key: "link_expiration", value: "0", type: "number", - help: "Link Expiration in Seconds", + // 单位与 Go 一致:小时(Go internal/sign 用 time.Duration(expire)*time.Hour, + // 官方文档 configuration/global.md 亦为 "in hours")。0 = 永不过期。 + help: "Link Expiration in Hours (0 = never expire)", group: 4, flag: 0, }, diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index 96b620be..ef235a8d 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -1,4 +1,6 @@ -import { resolvePath, getDb, saveDb } from "../model/db" +import { resolvePath, getDb, getSettings, saveDb } from "../model/db" +import { encodeDownloadPath } from "../../pkg/path" +import { canUseProxyEndpoint, normalizeExtList } from "../driver/proxy" import { FileItem, StorageDriver, calcFileType } from "../driver/base" import { Onedrive } from "../../drivers/onedrive/driver" import { OnedriveAPP } from "../../drivers/onedrive_app/driver" @@ -1362,7 +1364,19 @@ export async function listItems( const activeStorages = (db.storages || []).filter((s: any) => !s.disabled) const cleanListedPath = resolved.cleanPath - activeStorages.forEach((s: any) => { + // 挂载点顺序对齐 Go op.getStorageVirtualFilesByPath: + // Order 升序;Order 相同时按 MountPath 升序。 + // 此前直接沿用数据库数组顺序,后台存储编辑页里的「序号」因此完全不生效。 + const orderedStorages = [...activeStorages].sort((a: any, b: any) => { + const orderA = Number(a.order) || 0 + const orderB = Number(b.order) || 0 + if (orderA !== orderB) return orderA - orderB + const mountA = String(a.mount_path || "") + const mountB = String(b.mount_path || "") + return mountA < mountB ? -1 : mountA > mountB ? 1 : 0 + }) + + orderedStorages.forEach((s: any) => { const mount = "/" + (s.mount_path || "").split("/").filter(Boolean).join("/") if (mount === cleanListedPath || mount === "/") return @@ -1393,6 +1407,39 @@ export async function listItems( return { content: items, provider: driverName, storage: resolved.storage } } +/** + * raw_url 使用哪个端点前缀(`/api/p` 还是 `/api/d`)。 + * + * Go 的 `/p` 在取链接前先跑 `canProxy()`,不通过直接 403 `proxy not allowed`; + * `/d` 不受该限制(走 `ShouldProxy`)。因此 raw_url 必须与存储自身的代理配置匹配: + * 拿 `/p` 去请求一个既没开 `web_proxy`、扩展名也不在 `proxy_types` / `text_types` + * 里的存储,必然 403。 + * + * 这是 #66 修复后暴露的第二个问题:文件列表走 `/d` 所以正常,而预览页的「下载」 + * 按钮与 `img/video` 的 src 用的是 raw_url(此前恒为 `/api/p`)。 + */ +async function resolveRawUrlPrefix( + storage: any, + virtualPath: string, +): Promise { + try { + const settings: Record = await getSettings().catch( + () => ({}) as Record, + ) + const allowProxy = canUseProxyEndpoint({ + storage, + driver: storage?.driver || "", + filename: virtualPath, + proxyTypes: normalizeExtList(settings.proxy_types), + textTypes: normalizeExtList(settings.text_types), + }) + return allowProxy ? "/api/p" : "/api/d" + } catch { + // 设置读不到时保持旧行为(/p),由 rawRouter 给出最终结论 + return "/api/p" + } +} + export async function getItem( virtualPath: string, requestContext?: StorageRequestContext, @@ -1428,7 +1475,7 @@ export async function getItem( raw_url: "", }, provider: resolved.storage.driver, - rawUrl: `/api/p${virtualPath.startsWith("/") ? "" : "/"}${virtualPath}`, + rawUrl: `${await resolveRawUrlPrefix(resolved.storage, virtualPath)}${encodeDownloadPath(virtualPath)}`, } } @@ -1451,7 +1498,11 @@ export async function getItem( return { item, provider: driverName, - rawUrl: `/api/p${virtualPath.startsWith("/") ? "" : "/"}${virtualPath}`, + // 端点前缀按 canProxy() 选(见 resolveRawUrlPrefix),路径必须逐段编码 + // (对齐 Go utils.EncodePath(path, true)):raw_url 会被前端直接当 href/src + // 使用,未编码的 `?`/`#` 会被截断、裸 `%` 会让服务端 decodeURIComponent 抛错 + // (见 pkg/path.encodeDownloadPath 注释)。 + rawUrl: `${await resolveRawUrlPrefix(resolved.storage, virtualPath)}${encodeDownloadPath(virtualPath)}`, } } diff --git a/src/backend/pkg/path.ts b/src/backend/pkg/path.ts new file mode 100644 index 00000000..8fd7bce6 --- /dev/null +++ b/src/backend/pkg/path.ts @@ -0,0 +1,31 @@ +/** + * 路径编码工具(对齐 Go pkg/utils/path.go)。 + * + * 独立成文件是为了让 internal/op/storage.ts 这类底层模块能直接引用, + * 而不必把 pkg/utils 的整条依赖链(hono / model/db / 其他子模块)拉进来, + * 避免 storage → utils → db → storage 的循环导入。 + */ + +/** + * 逐段百分号编码下载路径(对齐 Go pkg/utils.EncodePath(path, true))。 + * + * Go 用的是 url.PathEscape 的 encodePath 模式:非保留字符 A-Za-z0-9-._~ + * 与子分隔符 $&+,:;=@ 原样保留,其余(`%`、`?`、`#`、空格、非 ASCII 等) + * 逐字节百分号编码;路径分隔符 `/` 保留(Go 先按段切分再编码,结果相同)。 + * + * 为什么下载链接必须编码:raw_url 会被前端原样当 href/src 使用 + * - 路径里未编码的 `?` / `#` 会被浏览器当成 query / fragment 截断; + * - 路径里出现裸 `%`(如 "100%.txt")会让服务端 decodeURIComponent 抛 + * URIError(raw.ts 的路径解析),而 Go 因编码了 `%` 不会遇到。 + */ +export function encodeDownloadPath(path: string): string { + const p = path.startsWith("/") ? path : `/${path}` + return p.replace(/[^A-Za-z0-9\-_.~$&+,:;=@/]/gu, (ch) => { + try { + return encodeURIComponent(ch) + } catch { + // 孤立代理项等非法 UTF-16:原样保留,绝不因编码失败抛错 + return ch + } + }) +} diff --git a/src/backend/pkg/sign.ts b/src/backend/pkg/sign.ts index c5a8b2ca..581c3210 100644 --- a/src/backend/pkg/sign.ts +++ b/src/backend/pkg/sign.ts @@ -8,16 +8,19 @@ import { getEnableSign } from "../internal/driver/storageopts" * * 启用条件(管理后台设置): * - sign_all === "true":所有下载链接签发 HMAC 签名; - * - link_expiration > 0:链接有效期(秒),超期失效。 + * - link_expiration > 0:链接有效期(**小时**,对齐 Go/官方文档),超期失效; + * 为 0 表示**永不过期**(Go:`LinkExpiration == 0` → `sign.NotExpired`)。 * 两者都关闭时本模块完全静默(enabled=false),行为与未启用完全一致。 * - * 签名格式:`${expires}.${hmacSha256(virtualPath:expires, secret)}` - * secret 复用 getJwtSecret(env.JWT_SECRET → KV 持久化随机 → 进程内随机), - * 与 JWT 同源但用途独立,无需额外配置。 + * 签名格式:`${expires}.${hmacSha256(virtualPath:expires, secret)}`; + * `expires === 0` 表示永不过期,验签时不做超期判定(对齐 Go pkg/sign 的 + * `expires != 0` 分支)。secret 复用 getJwtSecret(env.JWT_SECRET → KV/D1 + * 持久化随机 → 进程内随机),与 JWT 同源但用途独立,无需额外配置。 + * + * 与 Go 的差异(有意保留,不影响判签结果):Go 用 `base64url(hmac) + ":" + + * expire`,这里用 `expire + "." + hex(hmac)`,两种实现的签名不互通。 */ -const DEFAULT_SIGN_EXPIRES_SECONDS = 24 * 3600 // sign_all 开启但未配过期时默认 24h - /** 恒定时间比较(十六进制字符串),避免 HMAC 验签被时序侧信道攻击(M-1) */ function constantTimeEqualHex(a: string, b: string): boolean { if (typeof a !== "string" || typeof b !== "string") return false @@ -31,6 +34,7 @@ function constantTimeEqualHex(a: string, b: string): boolean { export interface SignPolicy { enabled: boolean + /** 签名有效期(**秒**)。0 表示永不过期(对齐 Go 的 expire == 0 语义)。 */ expiresIn: number } @@ -40,13 +44,15 @@ export async function getSignPolicy(c: any): Promise { const settings: Record = {} for (const s of db.settings || []) settings[s.key] = s.value const signAll = settings.sign_all === "true" - const linkExp = parseInt(settings.link_expiration, 10) || 0 - if (!signAll && linkExp <= 0) { + // link_expiration 的单位是**小时**:Go 用 time.Duration(expire)*time.Hour, + // 官方文档(configuration/global.md)亦写明 "in hours",0 = 永不过期。 + const linkExpHours = parseInt(settings.link_expiration, 10) || 0 + if (!signAll && linkExpHours <= 0) { return { enabled: false, expiresIn: 0 } } return { enabled: true, - expiresIn: linkExp > 0 ? linkExp : DEFAULT_SIGN_EXPIRES_SECONDS, + expiresIn: linkExpHours > 0 ? linkExpHours * 3600 : 0, } } catch { return { enabled: false, expiresIn: 0 } @@ -161,31 +167,40 @@ export async function needDownloadSign( } /** - * 签名有效期(秒)。link_expiration 优先,未配置则用默认值。 - * meta 密码保护路径即使未开 sign_all/link_expiration 也需要签发签名, - * 此时不能用 getSignPolicy().expiresIn(那里为 0,会导致签名立即过期)。 + * 签名有效期(**秒**,0 = 永不过期)。 + * + * link_expiration 以小时配置、为 0 时表示永不过期(对齐 Go sign.Sign 在 + * LinkExpiration == 0 时走 NotExpired)。meta 密码保护路径即使未开 + * sign_all/link_expiration 也需要签发签名,此时返回 0(永久),与 Go 一致。 */ export async function getSignExpiresIn(c: any): Promise { try { const db = await getDb(c?.env) for (const s of db.settings || []) { if (s.key === "link_expiration") { - const v = parseInt(s.value, 10) || 0 - if (v > 0) return v + const hours = parseInt(s.value, 10) || 0 + if (hours > 0) return hours * 3600 break } } } catch {} - return DEFAULT_SIGN_EXPIRES_SECONDS + return 0 } +/** + * 签发下载签名。 + * + * `expiresIn === 0` 表示**永不过期**(对齐 Go sign.NotExpired 的 expire=0), + * 传负数仍会得到「已过期」的签名(对齐 Go 传负 duration 的行为)。 + */ export async function signDownloadPath( c: any, virtualPath: string, expiresIn: number, ): Promise { const secret = await getJwtSecret(c) - const expires = Math.floor(Date.now() / 1000) + expiresIn + const expires = + expiresIn === 0 ? 0 : Math.floor(Date.now() / 1000) + expiresIn const hmac = await hmacSha256(`${virtualPath}:${expires}`, secret) return `${expires}.${hmac}` } @@ -199,7 +214,9 @@ export async function verifyDownloadSign( if (dot <= 0) return false const expires = parseInt(sign.slice(0, dot), 10) const hmac = sign.slice(dot + 1) - if (!Number.isFinite(expires) || expires <= Math.floor(Date.now() / 1000)) { + if (!Number.isFinite(expires)) return false + // expires === 0 表示永不过期(对齐 Go HMACSign.Verify 的 `expires != 0` 判定) + if (expires !== 0 && expires <= Math.floor(Date.now() / 1000)) { return false } const secret = await getJwtSecret(c) diff --git a/src/backend/pkg/utils.ts b/src/backend/pkg/utils.ts index 919db802..0ea9bd7a 100644 --- a/src/backend/pkg/utils.ts +++ b/src/backend/pkg/utils.ts @@ -6,6 +6,7 @@ import { getDb } from "../internal/model/db" */ export * from "./xml" +export * from "./path" export * from "./errs" export * from "./generic" export * from "./http" diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index 4e9479a1..9b4145bc 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -54,7 +54,14 @@ function generateSecureToken(length = 32): string { adminRouter.get("/storage/list", async (c) => { const db = await getDb(c.env) - const content = db.storages || [] + // 排序对齐 Go internal/db.GetStorages(addStorageOrder = `order, id`): + // 后台存储列表按「序号」升序、同序号按 id 升序,此前是按数组原始顺序返回。 + const content = [...(db.storages || [])].sort((a: any, b: any) => { + const orderA = Number(a.order) || 0 + const orderB = Number(b.order) || 0 + if (orderA !== orderB) return orderA - orderB + return (Number(a.id) || 0) - (Number(b.id) || 0) + }) return c.json({ code: 200, message: "success", diff --git a/src/backend/server/assets.ts b/src/backend/server/assets.ts index 4ef03e2e..df9b297f 100644 --- a/src/backend/server/assets.ts +++ b/src/backend/server/assets.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { getDb } from "../internal/model/db" /** - * 品牌资源路由。 + * 品牌资源 + CDN 静态资源路由。 * * 背景:老前端(含 logo.svg / logo.png / favicon 静态文件)已移除,前端统一 * 由官方 OpenList-Frontend 产物提供,但官方产物不包含 /logo.png、/favicon.png @@ -29,48 +29,56 @@ assetsRouter.get("/favicon.png", redirectToLogo) assetsRouter.get("/favicon.ico", redirectToLogo) /** - * CDN 静态资源重定向路由。 - * - * 当配置了 ASSET_URLS 时,前端静态资源(assets/、images/ 等)将重定向到 CDN 加载。 - * 支持 $version 占位符自动替换为前端版本号。 - * - * 参考原版 OpenList 实现:https://github.com/OpenListTeam/OpenList/blob/main/server/static/static.go - * - * 示例:ASSET_URLS = https://registry.npmmirror.com/@openlist-frontend/openlist-frontend/$version/files/dist + * 允许重定向到 CDN 的静态资源目录(对齐 Go server/static/static.go 的 + * `folders := []string{"assets", "images", "streamer", "static"}`)。 + * + * ⚠️ 这里**必须**是固定前缀白名单。此前用的是 `/:folder/:filepath*`—— + * 它会吞掉**任意两段路径**,于是「存储挂载点/文件」这种路由(例如 + * `<域名>/存储2/xxx.exe`,即文件预览页的地址)在刷新时会被它拦下, + * 未配置 ASSET_URLS 时直接返回 `Static resource not found`(404), + * 前端来不及走到 SPA 兜底,表现为「进预览页后刷新白屏/404」。 */ -assetsRouter.get("/:folder/:filepath*", async (c) => { - const env = c.env as any - const cdnUrl = env?.ASSET_URLS || process.env.ASSET_URLS - - if (!cdnUrl) { - // 未配置 CDN,返回 404 - return c.text("Static resource not found", 404) - } - - // 获取前端版本号 - const db = await getDb() +const CDN_FOLDERS = ["assets", "images", "streamer", "static"] + +/** 解析 CDN 模板:`$version` 占位符会替换为前端版本号(取不到则 latest) */ +async function resolveCdnBase(cdnUrl: string): Promise { let version = "latest" try { - const versionItem = db.get("SELECT * FROM x_settings WHERE key = 'version'") as any + const db = await getDb() + const versionItem = db.get( + "SELECT * FROM x_settings WHERE key = 'version'", + ) as any if (versionItem && versionItem.value) { - // 从版本字符串提取 frontend 版本,如 "v4.2.3 (Commit: xxx) - Frontend: v1.0.0 - Build at: xxx" + // 从版本字符串提取 frontend 版本,如 + // "v4.2.3 (Commit: xxx) - Frontend: v1.0.0 - Build at: xxx" const match = versionItem.value.match(/Frontend:\s*([^\s-]+)/) - if (match) { - version = match[1] - } + if (match) version = match[1] } - } catch (e) { - // 忽略错误,使用默认值 + } catch { + // 读不到版本号时退回 latest(与 Go 的 $version 缺省行为一致) } - - // 替换 $version 占位符 - const resolvedCdnUrl = cdnUrl.replace(/\$version/g, version) - - // 构建 CDN 资源完整 URL - const folder = c.req.param("folder") - const filepath = c.req.param("filepath") || "" - const resourceUrl = `${resolvedCdnUrl}/${folder}/${filepath}` - - // 重定向到 CDN 资源 - return c.redirect(resourceUrl, 302) -}) + return cdnUrl.replace(/\$version/g, version) +} + +for (const folder of CDN_FOLDERS) { + assetsRouter.get(`/${folder}/*`, async (c, next) => { + const env = c.env as any + const cdnUrl = + env?.ASSET_URLS || + (typeof process !== "undefined" ? process.env?.ASSET_URLS : "") || + "" + + // 未配置 CDN:不能在这里 404,必须放行给后面的本地静态资源 + //(Workers 的 ASSETS 绑定 / SPA 兜底),否则这些目录之外的请求不会受影响, + // 但 `/assets/...` 这类真实静态资源会直接 404。 + if (!cdnUrl) return await next() + + const resolvedCdnUrl = await resolveCdnBase(cdnUrl) + const prefix = `/${folder}/` + const filepath = c.req.path.startsWith(prefix) + ? c.req.path.slice(prefix.length) + : "" + + return c.redirect(`${resolvedCdnUrl}/${folder}/${filepath}`, 302) + }) +} diff --git a/src/backend/server/assets_route.test.ts b/src/backend/server/assets_route.test.ts new file mode 100644 index 00000000..0313e051 --- /dev/null +++ b/src/backend/server/assets_route.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { assetsRouter } from "./assets" + +/** + * 静态资源路由的回归:CDN 重定向**只能**匹配固定目录前缀。 + * + * 背景(实机复现):进入文件预览页后刷新,地址形如 `<域名>/存储2/xxx.exe`, + * 会被原先的贪婪路由 `/:folder/:filepath*` 拦下,未配置 ASSET_URLS 时直接 + * 返回 404 `Static resource not found`,前端来不及走到 SPA 兜底。 + * + * Go 的对应实现(server/static/static.go)只对 + * `assets / images / streamer / static` 四个目录做 CDN 重定向,其余一律 + * 交给 `noRoute`(返回 index.html)。 + */ + +/** 模拟 index.ts 的挂载顺序:assetsRouter 位于 SPA 兜底之前 */ +const appWithSpaFallback = () => { + const app = new Hono() + app.route("/", assetsRouter) + app.all("*", (c) => c.html("index")) + return app +} + +test("刷新文件预览页(<挂载点>/<文件>)必须落到 SPA 兜底,而不是 404", async () => { + const app = appWithSpaFallback() + for (const path of [ + "/%E5%AD%98%E5%82%A82/xxx.exe", + "/存储2/xxx.exe", + "/存储2/sub/dir/file.mp4", + ]) { + const res = await app.request(path, { + method: "GET", + headers: { Accept: "text/html,application/xhtml+xml" }, + }) + assert.equal(res.status, 200, `${path} -> ${res.status}`) + const body = await res.text() + assert.match(body, /index<\/title>/, `${path} 未返回 SPA 壳`) + assert.doesNotMatch(body, /Static resource not found/, `${path} 被 CDN 路由吞掉`) + } +}) + +test("未配置 ASSET_URLS 时,/assets/* 放行给本地静态资源(不返回 404 文案)", async () => { + const res = await appWithSpaFallback().request("/assets/index-abc.js", { + method: "GET", + }) + // 单测里没有 ASSETS 绑定,因此会落到 SPA 兜底;关键是不能再返回那句 404 + assert.notEqual(res.status, 404) + assert.doesNotMatch(await res.text(), /Static resource not found/) +}) + +test("配置 ASSET_URLS 时,仅四个静态目录 302 到 CDN,且 $version 被替换", async () => { + const env = { ASSET_URLS: "https://cdn.example.com/dist" } + const app = appWithSpaFallback() + + for (const folder of ["assets", "images", "streamer", "static"]) { + const res = await app.request(`/${folder}/a/b.js`, { method: "GET" }, env) + assert.equal(res.status, 302, `${folder} 未重定向`) + assert.equal( + res.headers.get("location"), + `https://cdn.example.com/dist/${folder}/a/b.js`, + ) + } + + // 非静态目录不受影响(仍是 SPA 壳) + const spa = await app.request("/存储2/xxx.exe", { method: "GET" }, env) + assert.equal(spa.status, 200) + assert.match(await spa.text(), /<title>index<\/title>/) +}) + +test("ASSET_URLS 含 $version 占位符时不留占位符(取不到版本则 latest)", async () => { + const env = { + ASSET_URLS: "https://cdn.example.com/openlist/$version/files/dist", + } + const res = await appWithSpaFallback().request( + "/assets/app.js", + { method: "GET" }, + env, + ) + assert.equal(res.status, 302) + const location = res.headers.get("location") || "" + assert.doesNotMatch(location, /\$version/) + assert.match(location, /\/assets\/app\.js$/) +}) + +test("logo / favicon 仍然重定向到官方 logo", async () => { + const res = await appWithSpaFallback().request("/favicon.ico", { method: "GET" }) + assert.equal(res.status, 302) + assert.equal(res.headers.get("location"), "https://res.oplist.org/logo/logo.svg") +}) diff --git a/src/backend/server/fs.ts b/src/backend/server/fs.ts index f0a7a3ac..ac90cef6 100644 --- a/src/backend/server/fs.ts +++ b/src/backend/server/fs.ts @@ -32,6 +32,7 @@ import { getSignExpiresIn, } from "../pkg/sign" import { safeErrorMessage } from "../pkg/errs" +import { encodeDownloadPath } from "../pkg/path" import { search } from "../internal/op/search" import { getDisableIndex, @@ -555,6 +556,24 @@ fsRouter.post("/get", async (c) => { signPolicy.expiresIn || (await getSignExpiresIn(c)), ) : "" + // raw_url 必须自带签名(Issue #66)。 + // + // 前端把本接口的 raw_url 直接当下载地址使用:预览页的「下载」按钮就是 + // <a href={raw_url}>(pages/home/previews/download.tsx),图片/视频预览也 + // 直接把它塞进 <img>/<video> 的 src,都不会再自己拼 ?sign=。而 raw_url + // 指向的是需要验签的 /p 或 /d 端点(前缀由 getItem 按 canProxy() 选定): + // sign_all、存储级 enable_sign、密码 meta 覆盖任一命中时,缺签名一律 + // 401 "sign verify failed"——外部表现就是「预览页能正常打开,一点下载就 401」。 + // + // 对齐 Go server/handles/fsread.go FsGet: + // if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) { + // query = "?sign=" + sign.Sign(reqPath) + // } + // rawURL = fmt.Sprintf("%s/p%s%s", common.GetApiUrl(c), ..., query) + const rawUrlWithSign = + sign && rawUrl && !/[?&]sign=/.test(rawUrl) + ? `${rawUrl}${rawUrl.includes("?") ? "&" : "?"}sign=${sign}` + : rawUrl const writable = canWrite(user) && canWriteMeta(user, meta, reqPath) const writeContentBypass = canWriteContentBypassUserPerms(meta, reqPath) @@ -607,7 +626,7 @@ fsRouter.post("/get", async (c) => { sign, thumb: (item as any).thumb || "", type: item.type ?? 0, - raw_url: rawUrl, + raw_url: rawUrlWithSign, readme: getReadme(meta, reqPath), header: getHeader(meta, reqPath), provider, @@ -1292,11 +1311,20 @@ fsRouter.post("/link", async (c) => { requestContext, ) } - // 无直链(如本地/加密驱动):返回代理下载地址 + // 无直链(如本地/加密驱动):返回代理下载地址。 + // + // 对齐 Go server/handles/fsmanage.go Link: + // fmt.Sprintf("%s/p%s?d&sign=%s", GetApiUrl(c), EncodePath(rawPath, true), sign.Sign(rawPath)) + // 即路径逐段编码 + **无条件**带签名(Go 不看 needSign;这里同样补齐, + // 否则在 sign_all / enable_sign / 密码 meta 生效时复制出来的链接必然 401)。 + // 「?d」(强制 attachment 下载)TS 侧无对应语义,故不追加。 + const sign = await signDownloadPath(c, reqPath, await getSignExpiresIn(c)) return c.json({ code: 200, message: "success", - data: { url: `/api/p${reqPath.startsWith("/") ? "" : "/"}${reqPath}` }, + data: { + url: `/api/p${encodeDownloadPath(reqPath)}?sign=${encodeURIComponent(sign)}`, + }, }) } catch (e: any) { return c.json({ code: 500, message: safeErrorMessage(e), data: null }, 500) diff --git a/src/backend/server/fs_get_rawurl_sign.test.ts b/src/backend/server/fs_get_rawurl_sign.test.ts new file mode 100644 index 00000000..73c284a6 --- /dev/null +++ b/src/backend/server/fs_get_rawurl_sign.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" +import { Hono } from "hono" +import { fsRouter } from "./fs" +import { rawRouter } from "./raw" +import { saveDb } from "../internal/model/db" +import { verifyDownloadSign } from "../pkg/sign" + +/** + * Issue #66 回归测试:[BUG] sign verify failed(下载 401,预览正常)。 + * + * 现象(issue 原文): + * GET https://xxx/api/p/存储1/7z2602-x64.exe → HTTP/2 401 + * 「下载文件出现 sign verify failed,不下载文件直接预览是正常的」 + * + * 根因:/fs/get 返回的 raw_url 形如 `/api/p/<path>`,指向的正是需要验签的 + * /p 端点,但**从来没带 ?sign=**;而前端把 raw_url 直接当下载地址用 + * - 预览页的下载按钮:<a href={objStore.raw_url}>(previews/download.tsx) + * - 图片/视频预览:<img src={objStore.raw_url}> / <video src={...}> + * 不会再自己拼签名(只有文件列表里的 /d、/p 链接才会用 fs/list 返回的 sign)。 + * + * 于是 sign_all / 存储级 enable_sign / 密码 meta 覆盖时: + * - .exe 这类「预览页只是元信息 + 下载按钮」的文件,页面能正常打开, + * 一点下载就 401 —— 正是 issue 描述的现象; + * 而 Go 版 server/handles/fsread.go FsGet 会显式补上: + * if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) { + * query = "?sign=" + sign.Sign(reqPath) + * } + */ + +const tmpRoots: string[] = [] + +/** 建一个临时目录并放入一个真实文件,挂成 Local 存储的根。 */ +function makeLocalRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openlist-rawurl-sign-")) + fs.writeFileSync(path.join(root, "a.exe"), "MZ") + tmpRoots.push(root) + return root +} + +const dbWith = ( + root: string, + settings: Array<{ key: string; value: string }> = [], +) => ({ + settings, + users: [ + { + id: 1, + username: "guest", + password: "xxx", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ], + storages: [ + { + id: "s1", + driver: "Local", + mount_path: "/local", + addition: JSON.stringify({ root_folder_path: root }), + modified: "2026-01-01T00:00:00.000Z", + disabled: false, + }, + ], + shares: [], + metas: [], +}) + +const appOf = () => { + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/p", rawRouter) + return app +} + +const signOf = (rawUrl: string) => + new URL(rawUrl, "http://localhost").searchParams.get("sign") || "" + +test("fs/get: raw_url 自带签名,且该签名能通过 /p 验签(Issue #66)", async () => { + const env: any = {} + const root = makeLocalRoot() + await saveDb(dbWith(root, [{ key: "sign_all", value: "true" }]), env) + + const app = appOf() + const res = await app.request("/api/fs/get", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/local/a.exe" }), + }) + const body: any = await res.json() + assert.equal(body.code, 200, `fs/get failed: ${JSON.stringify(body)}`) + + const rawUrl: string = body.data.raw_url + assert.ok( + rawUrl.startsWith("/api/p/local/a.exe"), + `raw_url should stay a proxy url, got ${rawUrl}`, + ) + // 缺这一条就是 bug 本体:前端直接跳 raw_url,服务器却没拿到签名 + assert.ok( + /[?&]sign=/.test(rawUrl), + `raw_url must carry the download sign, got ${rawUrl}`, + ) + // 客户端用的 sign 字段与 raw_url 里的必须一致(前端另一处用它拼 /d 链接) + assert.equal(signOf(rawUrl), body.data.sign) + assert.equal(await verifyDownloadSign(env, "/local/a.exe", body.data.sign), true) + + // 端到端:浏览器直接打开 raw_url 不能再 401(issue 里就是这一步) + const hit = await app.request(rawUrl, { method: "GET" }) + assert.notEqual( + hit.status, + 401, + "raw_url returned by /fs/get must be accepted by /p (was 401 before the fix)", + ) +}) + +test("fs/get: 不需要签名时不追加 sign(保持公开直链语义)", async () => { + const env: any = {} + const root = makeLocalRoot() + await saveDb(dbWith(root), env) + + const res = await appOf().request("/api/fs/get", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/local/a.exe" }), + }) + const body: any = await res.json() + assert.equal(body.code, 200) + assert.equal(body.data.sign, "") + assert.equal( + body.data.raw_url, + "/api/p/local/a.exe", + "public path keeps a bare proxy url", + ) +}) + +test("/p 幂等:raw_url 中的签名被篡改即 401(对照,证明断言有效)", async () => { + const env: any = {} + const root = makeLocalRoot() + await saveDb(dbWith(root, [{ key: "sign_all", value: "true" }]), env) + + const app = appOf() + const res = await app.request("/api/fs/get", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/local/a.exe" }), + }) + const body: any = await res.json() + const sign: string = body.data.sign + const tampered = sign.slice(0, -1) + (sign.endsWith("a") ? "b" : "a") + + const hit = await app.request( + `/api/p/local/a.exe?sign=${encodeURIComponent(tampered)}`, + { method: "GET" }, + ) + assert.equal(hit.status, 401) +}) + +test("cleanup", () => { + for (const root of tmpRoots) { + try { + fs.rmSync(root, { recursive: true, force: true }) + } catch {} + } +}) diff --git a/src/backend/server/raw.ts b/src/backend/server/raw.ts index d852ebce..f1894ffa 100644 --- a/src/backend/server/raw.ts +++ b/src/backend/server/raw.ts @@ -7,10 +7,10 @@ import { needDownloadSign, verifyDownloadSign, signDownloadPath, - getSignPolicy, getSignExpiresIn, } from "../pkg/sign" import { safeErrorMessage } from "../pkg/errs" +import { encodeDownloadPath } from "../pkg/path" import { assertSafeUrl, getTrustedHosts } from "../pkg/http" import { resolveProxyDecision, @@ -315,28 +315,20 @@ async function proxyUpstream( } /** - * 判断某个 URL 是否指向本站(用于决定是否附带下载签名)。 - * 本站地址通常是 /p/... 之类的相对路径,或与请求同 host 的绝对地址。 - */ -function isSameOriginUrl(url: string, c: any): boolean { - if (url.startsWith("/")) return true - try { - const target = new URL(url) - const host = c.req.header("host") || new URL(c.req.url).host - return target.host === host - } catch { - return false - } -} - -/** - * 构造 down_proxy_url 形式的下载地址。 + * 构造 down_proxy_url 形式的下载地址(对齐 Go common.GenerateDownProxyURL)。 * - * 对齐 Go internal/common/url.go 的 DownloadProxyURL:模板里的 $path 会被替换为 - * 真实路径;若模板以 / 开头则视为同源相对路径,否则应为 http(s) 绝对地址。 + * Go 的实现是: + * fmt.Sprintf("%s%s%s", strings.Split(DownProxyURL, "\n")[0], + * utils.EncodePath(reqPath, true), query) + * 即**总是**把编码后的真实路径拼在模板之后、并在未禁用时补上 `?sign=`: + * - 模板里没有 `$path` 概念(全仓库搜不到该占位符);TS 额外支持 `$path` + * 替换,属于向后兼容的超集,无 `$path` 的模板行为与 Go 完全一致。 + * - 补签只看 `disable_proxy_sign`,**不限定同源**:down_proxy_url 的典型 + * 用法正是指向另一个域名(CDN / 前置代理 / 同实例的另一域名),此前 + * 只在"与请求同 host"时才补签,会让这类配置重定向到一个必然 401 的地址。 * * 注意:模板不携带本服务实例的密钥,因此 worker 场景下无法预先把签名写进模板, - * 这里在运行时补签(仅当目标是本站 且 需要签名 且 未禁用 disable_proxy_sign)。 + * 只能在运行时补签。 */ async function buildDownProxyUrl( c: any, @@ -345,27 +337,20 @@ async function buildDownProxyUrl( storage: any, ): Promise<string> { if (!template) return "" - const encoded = encodeURI(reqPath.startsWith("/") ? reqPath : "/" + reqPath) + const encoded = encodeDownloadPath(reqPath) let url = template.includes("$path") ? template.replace(/\$path(?!\w)/g, encoded) : template.replace(/\/+$/, "") + encoded - if ( - isSameOriginUrl(url, c) && - !getDisableProxySign(storage) && - !/[?&]sign=/.test(url) - ) { + if (!getDisableProxySign(storage) && !/[?&]sign=/.test(url)) { try { - const policy = await getSignPolicy(c) - if (policy.enabled) { - const sign = await signDownloadPath( - c, - reqPath, - await getSignExpiresIn(c), - ) - if (sign) url += (url.includes("?") ? "&" : "?") + "sign=" + sign - } + const sign = await signDownloadPath( + c, + reqPath, + await getSignExpiresIn(c), + ) + if (sign) url += (url.includes("?") ? "&" : "?") + "sign=" + sign } catch (e: any) { console.warn( `[rawRouter] failed to sign down_proxy_url for '${reqPath}': ${e?.message || e}`, @@ -396,7 +381,15 @@ rawRouter.get("/*", async (c) => { "", ) - const reqPath0 = decodeURIComponent(rawPath) + // 非法百分号转义(如手工拼出的 `/api/p/100%.txt`)会让 decodeURIComponent + // 抛 URIError。Go 侧因为 raw_url 走 EncodePath 编码过 `%`,正常流程不会出现; + // 但外部/手工 URL 仍可能出现,这里按 400 处理而不是冒泡成 500。 + let reqPath0: string + try { + reqPath0 = decodeURIComponent(rawPath) + } catch { + return c.text("Bad Request: malformed path encoding", 400) + } try { let reqPath = reqPath0 diff --git a/src/backend/server/raw_url_prefix.test.ts b/src/backend/server/raw_url_prefix.test.ts new file mode 100644 index 00000000..d9a5493a --- /dev/null +++ b/src/backend/server/raw_url_prefix.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { getItem } from "../internal/op/storage" +import { saveDb } from "../internal/model/db" +import { canUseProxyEndpoint } from "../internal/driver/proxy" +import { rawRouter } from "./raw" + +/** + * #66 后续:`raw_url` 的端点前缀必须与存储自身的代理配置匹配。 + * + * `/p` 与 `/d` 不是等价的: + * - `/p` 会先跑 Go 的 `canProxy()`(`MustProxy || WebProxy || webdav_policy= + * use_proxy_url || proxy_types || text_types`),不通过直接 + * `403 proxy not allowed`; + * - `/d` 不受该门禁限制(Go 的 `/d` 走 `ShouldProxy`)。 + * + * 因此 raw_url 恒为 `/api/p` 时,凡是没有开启代理的存储,只要从预览页点「下载」 + * (或让 `img/video` 去取 raw_url)就会 403;而文件列表右键下载走 `/d`,所以看起来 + * 「只有预览页下载坏掉」。 + */ + +const storageRow = (driver: string, extra: Record<string, any> = {}) => ({ + id: "s1", + driver, + mount_path: "/np", + addition: "{}", + modified: "2026-01-01T00:00:00.000Z", + disabled: false, + ...extra, +}) + +const dbWith = (storages: any[], settings: any[] = []) => ({ + settings, + users: [], + storages, + shares: [], + metas: [], +}) + +const appOf = () => { + const app = new Hono() + app.route("/api/p", rawRouter) + app.route("/api/d", rawRouter) + return app +} + +test("非代理存储:raw_url 用 /api/d(/p 会被 canProxy 拒绝)", async () => { + const env: any = {} + await saveDb(dbWith([storageRow("gen")]), env) + + const { rawUrl } = await getItem("/np", { env }) + assert.equal(rawUrl, "/api/d/np") +}) + +test("web_proxy=true 的存储:raw_url 用 /api/p", async () => { + const env: any = {} + await saveDb(dbWith([storageRow("gen", { web_proxy: true })]), env) + + const { rawUrl } = await getItem("/np", { env }) + assert.equal(rawUrl, "/api/p/np") +}) + +test("扩展名命中 proxy_types / text_types 时才允许 /p", () => { + const storage = storageRow("gen") + const check = (filename: string, proxyTypes: unknown, textTypes: unknown) => + canUseProxyEndpoint({ + storage, + driver: "gen", + filename, + proxyTypes, + textTypes, + }) + + assert.equal(check("/np/a.exe", [], []), false) + assert.equal(check("/np/a.exe", ["exe"], []), true) + assert.equal(check("/np/a.txt", [], ["txt"]), true) +}) + +test("HTTP 层面:/p 对非代理存储回 403 proxy not allowed,/d 不受该门禁限制", async () => { + const env: any = {} + await saveDb(dbWith([storageRow("gen")]), env) + const app = appOf() + + const viaProxy = await app.request("/api/p/np/a.exe", { method: "GET" }) + assert.equal(viaProxy.status, 403) + assert.match(await viaProxy.text(), /proxy not allowed/) + + // 同一路径走 /d:不触发 canProxy 门禁(这里会因驱动不存在而 404,但不能是 403) + const viaDirect = await app.request("/api/d/np/a.exe", { method: "GET" }) + assert.notEqual( + viaDirect.status, + 403, + "/d 不应受 canProxy() 限制,否则非代理存储的下载/预览会全部 403", + ) +}) diff --git a/src/backend/server/seed.ts b/src/backend/server/seed.ts index 976e1fef..e5781b80 100644 --- a/src/backend/server/seed.ts +++ b/src/backend/server/seed.ts @@ -282,12 +282,16 @@ async function readStoredSeed( path: string, ): Promise<Uint8Array> { const actualPath = getActualPath(user, normalizeVirtualPath(path)) - const { item } = await getItem(actualPath, storageContext(c)) + const { item, rawUrl: apiRawUrl } = await getItem( + actualPath, + storageContext(c), + ) const max = envNumber(c, "SEED_MAX_METADATA_SIZE", DEFAULT_MAX_SEED_BYTES) if (item.is_dir || item.size < 0 || item.size > max) throw new Error(`Seed file exceeds the ${max} byte limit`) - const rawUrl = - item.raw_url || `${new URL(c.req.url).origin}/api/p${actualPath}` + // 驱动直链优先;否则回退到 getItem 给出的代理地址(前缀已按 canProxy() 选好, + // 路径已编码,不能在这里硬编码 /api/p,否则未开代理的存储会 403)。 + const rawUrl = item.raw_url || new URL(apiRawUrl, c.req.url).toString() const headers = item.raw_url_headers || {} if (!item.raw_url && c.req.header("Authorization")) headers.Authorization = c.req.header("Authorization")! @@ -372,10 +376,13 @@ async function collectSourceFiles( if (result.length >= maxFiles) throw new Error(`Seed file count exceeds the ${maxFiles} file limit`) const actualPath = getActualPath(user, virtualPath) - const { item } = await getItem(actualPath, storageContext(c)) + const { item, rawUrl: apiRawUrl } = await getItem( + actualPath, + storageContext(c), + ) if (!item.is_dir) { - const rawUrl = - item.raw_url || `${new URL(c.req.url).origin}/api/p${actualPath}` + // 同 readStoredSeed:直链优先,否则用 getItem 的代理地址(前缀/编码已定) + const rawUrl = item.raw_url || new URL(apiRawUrl, c.req.url).toString() const headers = { ...(item.raw_url_headers || {}) } if (!item.raw_url && c.req.header("Authorization")) headers.Authorization = c.req.header("Authorization")! diff --git a/src/backend/server/sign_go_parity.test.ts b/src/backend/server/sign_go_parity.test.ts new file mode 100644 index 00000000..594a8bea --- /dev/null +++ b/src/backend/server/sign_go_parity.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" +import { Hono } from "hono" +import { fsRouter } from "./fs" +import { rawRouter } from "./raw" +import { saveDb } from "../internal/model/db" +import { encodeDownloadPath } from "../pkg/path" +import { + getSignExpiresIn, + getSignPolicy, + signDownloadPath, + verifyDownloadSign, +} from "../pkg/sign" + +/** + * 与 Go 版对齐的两处语义回归: + * + * 1. raw_url 的路径编码 —— Go 用 utils.EncodePath(reqPath, true) + * (url.PathEscape 的 encodePath 模式:保留 A-Za-z0-9-._~ 与 $&+,:;=@, + * 其余含 %、?、#、空格、非 ASCII 逐字节编码,保留 `/`)。 + * 未编码时 `?`/`#` 会被浏览器当成 query/fragment 截断;裸 `%` 会让服务端 + * decodeURIComponent 抛 URIError(raw.ts 路径解析)。 + * + * 2. link_expiration 的单位与 0 语义 —— Go internal/sign: + * expire == 0 → NotExpired(永不过期);否则 time.Duration(expire)*time.Hour + * (**小时**,官方文档 configuration/global.md 亦为 "in hours")。 + */ + +const tmpRoots: string[] = [] + +function makeLocalRoot(files: string[]): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openlist-parity-")) + for (const name of files) fs.writeFileSync(path.join(root, name), "x") + tmpRoots.push(root) + return root +} + +const dbWith = ( + root: string, + settings: Array<{ key: string; value: string }> = [], +) => ({ + settings, + users: [ + { + id: 1, + username: "guest", + password: "xxx", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ], + storages: [ + { + id: "s1", + driver: "Local", + mount_path: "/local", + addition: JSON.stringify({ root_folder_path: root }), + modified: "2026-01-01T00:00:00.000Z", + disabled: false, + }, + ], + shares: [], + metas: [], +}) + +const emptyDb = (settings: Array<{ key: string; value: string }> = []) => ({ + settings, + users: [], + storages: [], + shares: [], + metas: [], +}) + +const appOf = () => { + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/p", rawRouter) + return app +} + +// --------------------------------------------------------------------------- +// 1. 路径编码(Go utils.EncodePath(path, true)) +// --------------------------------------------------------------------------- + +test("encodeDownloadPath 与 Go EncodePath(path, true) 的字符集合一致", () => { + // 保留:字母数字、-_.~ 与子分隔符 $&+,:;=@,以及路径分隔符 / + assert.equal( + encodeDownloadPath("/a-b_c.d~e/f$g&h+i,j:k;l=m@n.txt"), + "/a-b_c.d~e/f$g&h+i,j:k;l=m@n.txt", + ) + // 编码:%、?、#、空格、非 ASCII(逐字节 %XX) + assert.equal(encodeDownloadPath("/100%.txt"), "/100%25.txt") + assert.equal(encodeDownloadPath("/a?b.txt"), "/a%3Fb.txt") + assert.equal(encodeDownloadPath("/a#b.txt"), "/a%23b.txt") + assert.equal(encodeDownloadPath("/a b.txt"), "/a%20b.txt") + assert.equal( + encodeDownloadPath("/存储1/7z.exe"), + "/%E5%AD%98%E5%82%A81/7z.exe", + ) + // 缺少前导 / 时补上(与 Go 的 reqPath 一定是绝对路径一致) + assert.equal(encodeDownloadPath("a.txt"), "/a.txt") +}) + +test("fs/get:raw_url 编码后,含 % / # / 中文 的文件名不再被截断或 500", async () => { + const env: any = {} + // 注意:`?` 在 Windows 上无法作为文件名,其编码行为由上一条单元用例覆盖 + const names = ["100%.txt", "a#b.txt", "存储1.exe"] + const root = makeLocalRoot(names) + await saveDb(dbWith(root, [{ key: "sign_all", value: "true" }]), env) + const app = appOf() + + for (const name of names) { + const res = await app.request("/api/fs/get", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: `/local/${name}` }), + }) + const body: any = await res.json() + assert.equal(body.code, 200, `${name}: ${JSON.stringify(body)}`) + const rawUrl: string = body.data.raw_url + assert.ok( + rawUrl.startsWith("/api/p" + encodeDownloadPath(`/local/${name}`)), + `${name}: raw_url not encoded -> ${rawUrl}`, + ) + // 关键:这个 http 请求过去会抛 URIError(裸 %)或被 ? 截断成另一个路径 + const hit = await app.request(rawUrl, { method: "GET" }) + assert.notEqual(hit.status, 401, `${name}: raw_url rejected -> ${hit.status}`) + assert.notEqual(hit.status, 500, `${name}: raw_url crashed -> ${hit.status}`) + } +}) + +test("/p 对非法百分号转义回 400(而不是 URIError 冒泡成 500)", async () => { + const env: any = {} + await saveDb(dbWith(makeLocalRoot(["a.txt"])), env) + const res = await appOf().request("/api/p/local/100%.txt", { method: "GET" }) + assert.equal(res.status, 400) +}) + +// --------------------------------------------------------------------------- +// 2. link_expiration 单位(小时)与 0 = 永不过期 +// --------------------------------------------------------------------------- + +test("link_expiration 以小时计:24 → 有效期 86400 秒(与 Go *time.Hour 一致)", async () => { + const env: any = {} + await saveDb(emptyDb([{ key: "link_expiration", value: "24" }]), env) + + const policy = await getSignPolicy({ env }) + assert.equal(policy.enabled, true) + assert.equal(policy.expiresIn, 24 * 3600) + assert.equal(await getSignExpiresIn({ env }), 24 * 3600) + + const sign = await signDownloadPath({ env }, "/a.txt", 24 * 3600) + const expires = parseInt(sign.slice(0, sign.lastIndexOf(".")), 10) + const now = Math.floor(Date.now() / 1000) + assert.ok( + Math.abs(expires - (now + 86400)) <= 5, + `expires should be ~now+86400s, got ${expires - now}s`, + ) +}) + +test("link_expiration=0 + sign_all:签发永不过期签名(expire=0,Go NotExpired)", async () => { + const env: any = {} + await saveDb( + emptyDb([ + { key: "sign_all", value: "true" }, + { key: "link_expiration", value: "0" }, + ]), + env, + ) + + const policy = await getSignPolicy({ env }) + assert.equal(policy.enabled, true) + assert.equal(policy.expiresIn, 0, "0 = 永不过期") + assert.equal(await getSignExpiresIn({ env }), 0) + + const sign = await signDownloadPath( + { env }, + "/a.txt", + await getSignExpiresIn({ env }), + ) + assert.ok(sign.startsWith("0."), `expire 字段应为 0,实际 ${sign.slice(0, 12)}`) + assert.equal(await verifyDownloadSign({ env }, "/a.txt", sign), true) +}) + +test("负数有效期仍视为已过期(对齐 Go 传负 duration 的行为)", async () => { + const env: any = {} + await saveDb(emptyDb(), env) + const sign = await signDownloadPath({ env }, "/a.txt", -10) + assert.equal(await verifyDownloadSign({ env }, "/a.txt", sign), false) +}) + +test("cleanup", () => { + for (const root of tmpRoots) { + try { + fs.rmSync(root, { recursive: true, force: true }) + } catch {} + } +}) diff --git a/src/backend/server/storage_order.test.ts b/src/backend/server/storage_order.test.ts new file mode 100644 index 00000000..628e5c14 --- /dev/null +++ b/src/backend/server/storage_order.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { listItems } from "../internal/op/storage" +import { saveDb } from "../internal/model/db" +import { adminRouter } from "./admin" + +/** + * 存储「序号」(storage.order)必须真实生效 —— 对齐 Go: + * + * - 虚拟根目录的挂载点顺序:`op.getStorageVirtualFilesByPath` + * 先按 `Order` 升序,`Order` 相同时按 `MountPath` 升序; + * - 后台存储列表:`internal/db.GetStorages`(`addStorageOrder` = `order, id`)。 + * + * 此前 TS 两处都直接沿用数据库数组顺序,后台改「序号」后排序完全不变。 + */ + +const storageRow = ( + id: number, + mount: string, + order: number, + extra: Record<string, any> = {}, +) => ({ + id, + driver: "gen", + mount_path: mount, + addition: "{}", + modified: "2026-01-01T00:00:00.000Z", + disabled: false, + order, + ...extra, +}) + +const dbWith = (storages: any[], settings: any[] = []) => ({ + settings, + users: [], + storages, + shares: [], + metas: [], +}) + +test("根目录挂载点按 storage.order 升序、同序号按 mount_path 升序", async () => { + const env: any = {} + await saveDb( + dbWith([ + // 数据库里的顺序刻意与 order 相反,确保测的是 order 而不是数组下标 + storageRow(11, "/c", 2), + storageRow(12, "/a", 1), + storageRow(13, "/b", 2), + ]), + env, + ) + + const { content } = await listItems("/", { env }) + assert.deepEqual( + content.map((i) => i.name), + ["a", "b", "c"], + ) +}) + +test("序号相同时按 mount_path 升序(不依赖 id / 插入顺序)", async () => { + const env: any = {} + await saveDb( + dbWith([ + storageRow(21, "/zzz", 0), + storageRow(22, "/aaa", 0), + storageRow(23, "/mmm", 0), + ]), + env, + ) + + const { content } = await listItems("/", { env }) + assert.deepEqual( + content.map((i) => i.name), + ["aaa", "mmm", "zzz"], + ) +}) + +test("被禁用的存储不参与挂载点合并", async () => { + const env: any = {} + await saveDb( + dbWith([ + storageRow(31, "/keep", 1), + storageRow(32, "/gone", 2, { disabled: true }), + ]), + env, + ) + + const { content } = await listItems("/", { env }) + assert.deepEqual( + content.map((i) => i.name), + ["keep"], + ) +}) + +test("后台存储列表按 order, id 升序返回(对齐 Go db.GetStorages)", async () => { + const env: any = { } + await saveDb( + dbWith( + [ + storageRow(42, "/b", 5), + storageRow(41, "/a", 5), + storageRow(43, "/c", 1), + ], + [{ key: "token", value: "admin-token" }], + ), + env, + ) + + const app = new Hono() + app.route("/api/admin", adminRouter) + const res = await app.request( + "/api/admin/storage/list", + { method: "GET", headers: { Authorization: "Bearer admin-token" } }, + env, + ) + assert.equal(res.status, 200) + const body: any = await res.json() + assert.deepEqual( + body.data.content.map((s: any) => s.mount_path), + ["/c", "/a", "/b"], + ) +}) diff --git a/src/backend/server/webdav.ts b/src/backend/server/webdav.ts index 2ceb81fd..eefbcff8 100644 --- a/src/backend/server/webdav.ts +++ b/src/backend/server/webdav.ts @@ -12,8 +12,7 @@ import { } from "../internal/op/storage" import { buildWebDavPropfindResponse } from "../internal/webdav/webdav" import { safeErrorMessage } from "../pkg/errs" -import { getSettings, resolvePath } from "../internal/model/db" -import { canUseProxyEndpoint, normalizeExtList } from "../internal/driver/proxy" +import { encodeDownloadPath } from "../pkg/path" /** * WebDAV 协议服务(挂载于 /dav/*)。 @@ -153,32 +152,11 @@ webdavRouter.all("/*", async (c) => { // 重定向到 rawRouter 实际下载;rawRouter 已处理所有驱动的下载协议 // (proxy/redirect/stream + Range + SSRF 防护)。 // - // 走 /p 还是 /d 取决于存储的代理策略:/p 是受限的公开代理端点 - // (对齐 Go handles.canProxy(),未开启代理的存储会 403),而 WebDAV 协议 - // 拉流必须能拿到字节——不能拿直链的存储(如 WebDav 自身)才需要 /p。 - // 因此这里按同一个判据选择端点,避免 WebDAV 客户端读到 403。 - let prefix = "/api/p" - try { - const resolved: any = await resolvePath(davPath) - const storage = resolved?.storage - if (storage) { - const settings: Record<string, any> = await getSettings().catch( - () => ({}) as Record<string, any>, - ) - const allowProxy = canUseProxyEndpoint({ - storage, - driver: storage.driver, - filename: davPath, - proxyTypes: normalizeExtList(settings.proxy_types), - textTypes: normalizeExtList(settings.text_types), - }) - if (!allowProxy) prefix = "/api/d" - } - } catch { - // 解析失败时保持默认 /p,交由 rawRouter 给出最终结论 - } + // 端点前缀(/p 还是 /d)与路径编码都由 getItem 决定(见 + // op/storage.ts resolveRawUrlPrefix):/p 受 Go canProxy() 限制,未开启 + // 代理的存储会 403 proxy not allowed,因此不能在这里硬编码 /p。 return c.redirect( - rawUrl || `${prefix}${davPath.startsWith("/") ? "" : "/"}${davPath}`, + rawUrl || `/api/d${encodeDownloadPath(davPath)}`, 302, ) }