Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/backend/internal/driver/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 内的别名(兼容历史写法)
Expand All @@ -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 ""
}
Expand Down
4 changes: 3 additions & 1 deletion src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
59 changes: 55 additions & 4 deletions src/backend/internal/op/storage.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string> {
try {
const settings: Record<string, any> = await getSettings().catch(
() => ({}) as Record<string, any>,
)
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,
Expand Down Expand Up @@ -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)}`,
}
}

Expand All @@ -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)}`,
}
}

Expand Down
31 changes: 31 additions & 0 deletions src/backend/pkg/path.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
}
51 changes: 34 additions & 17 deletions src/backend/pkg/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +34,7 @@ function constantTimeEqualHex(a: string, b: string): boolean {

export interface SignPolicy {
enabled: boolean
/** 签名有效期(**秒**)。0 表示永不过期(对齐 Go 的 expire == 0 语义)。 */
expiresIn: number
}

Expand All @@ -40,13 +44,15 @@ export async function getSignPolicy(c: any): Promise<SignPolicy> {
const settings: Record<string, string> = {}
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 }
Expand Down Expand Up @@ -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<number> {
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<string> {
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}`
}
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/backend/pkg/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion src/backend/server/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 47 additions & 39 deletions src/backend/server/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string> {
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)
})
}
Loading
Loading