Skip to content
Open
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
44 changes: 36 additions & 8 deletions esa-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ function deleteModuleKvCache(key: string): void {
moduleKvCache.delete(key)
}

/**
* 模块级 KV 探测结果缓存。
*
* probe 的目的只是诊断 KV 可用性(结果仅用于日志),但裸调 edgeKv.get()
* 每个请求都会烧掉 1 次 KV 子请求配额(ESA 每请求限 8 次)。
* 这里将探测结果缓存 60 秒:函数实例存活期内后续请求直接复用结果,
* 既保留故障可见性,又不与业务 get 争抢配额。
*/
const KV_PROBE_TTL_MS = 60_000
let kvProbeCache: { ok: boolean; err: string | null; at: number } | null = null

/**
* 包装 ESA EdgeKV 为项目通用的 { get, put, delete } 接口。
* @param edgeKv 原始 EdgeKV 实例
Expand Down Expand Up @@ -152,8 +163,13 @@ function wrapEsaEdgeKV(edgeKv: any, cache?: Map<string, string | null>) {
async delete(key: string): Promise<void> {
try {
await edgeKv.delete(key)
} catch {}
// delete 后使缓存失效
} catch (e: any) {
// 删除失败必须抛出(与 put 一致):吞掉错误后再失效本地缓存,
// 会让调用方误以为删除成功,而下次 get 又读到旧值(数据"删不掉"且无日志)。
console.error(`[ESA/KV] delete failed key=${key}:`, e?.message || e)
throw e
}
// delete 成功后才使缓存失效
if (cache) cache.delete(key)
deleteModuleKvCache(key)
},
Expand Down Expand Up @@ -182,14 +198,26 @@ export default {
if (edgeKvCtor) {
try {
const edgeKv = new edgeKvCtor({ namespace })
// probe 本身也走缓存,避免和业务 get 争抢配额
// probe 走模块级缓存(60s TTL):探测是诊断行为,结果仅用于日志,
// 不应每请求都烧 1 次 KV 子请求配额。注意必须裸调 edgeKv 而非 wrappedKv,
// 否则包装层吞错后 probe 永远报 ok,失去故障可见性。
let kvTestOk = false
let kvTestErr: string | null = null
try {
await edgeKv.get("__openlist_probe__")
kvTestOk = true
} catch (e: any) {
kvTestErr = e?.message || String(e)
if (
kvProbeCache &&
Date.now() - kvProbeCache.at <= KV_PROBE_TTL_MS
) {
kvTestOk = kvProbeCache.ok
kvTestErr = kvProbeCache.err
} else {
try {
await edgeKv.get("__openlist_probe__")
kvTestOk = true
kvProbeCache = { ok: true, err: null, at: Date.now() }
} catch (e: any) {
kvTestErr = e?.message || String(e)
kvProbeCache = { ok: false, err: kvTestErr, at: Date.now() }
}
}
// 传入请求级缓存
const wrappedKv = wrapEsaEdgeKV(edgeKv, kvCache)
Expand Down
12 changes: 10 additions & 2 deletions loadEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import path from "path";
try {
const envPath = path.resolve(process.cwd(), ".env");
const envFile = fs.readFileSync(envPath, "utf-8");
envFile.split("\n").forEach(line => {
envFile.split(/\r?\n/).forEach(line => {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
// 剥离 CRLF 残留与成对引号:JWT_SECRET="abc" 应得到 abc 而不是 "abc"(含引号 8 字符)
let value = (match[2] ?? "").trim();
if (
(value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
) {
value = value.slice(1, -1);
}
if (!process.env[match[1]]) {
process.env[match[1]] = match[2];
process.env[match[1]] = value;
}
}
});
Expand Down
8 changes: 7 additions & 1 deletion middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ export function middleware(context) {
const { pathname } = new URL(request.url)
const accept = request.headers.get("accept") || ""

// /dav(WebDAV)与 /s3(S3 网关)也是后端路由(src/backend/index.ts 挂载),
// 必须放行到云函数:否则浏览器以 text/html 访问 /dav/*、/s3/* 时会被改写为
// /index.html,返回 SPA 壳而非后端响应。
const isBackend =
pathname === "/health" || /^\/(api|d|p|sd|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(pathname)
pathname === "/health" ||
/^\/(api|d|p|sd|dav|s3|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(
pathname,
)

if (
!isBackend &&
Expand Down
18 changes: 15 additions & 3 deletions src/backend/drivers/s3/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export function parseListObjectsV1(
const size = parseInt(parseXmlTag(block, "Size") || "0", 10)
const modified =
parseXmlTag(block, "LastModified") || new Date().toISOString()
const etag = parseXmlTag(block, "ETag")?.replace(/"/g, "")
// ETag 在 XML 里形如 &quot;hex&quot;,必须先反转义再剥引号,否则 etag 值带实体前缀
const etag = unescapeXml(parseXmlTag(block, "ETag") || "").replace(
/"/g,
"",
)

files.push({
name,
Expand Down Expand Up @@ -199,7 +203,11 @@ export function parseListObjectsV2(
const size = parseInt(parseXmlTag(block, "Size") || "0", 10)
const modified =
parseXmlTag(block, "LastModified") || new Date().toISOString()
const etag = parseXmlTag(block, "ETag")?.replace(/"/g, "")
// 同 V1:ETag 需先反转义再剥引号
const etag = unescapeXml(parseXmlTag(block, "ETag") || "").replace(
/"/g,
"",
)

files.push({
name,
Expand Down Expand Up @@ -467,7 +475,11 @@ export class S3Client {
if (result.nextMarker) {
marker = result.nextMarker
} else if (result.files.length > 0) {
marker = result.files[result.files.length - 1].path
// 兜底 marker 必须是 S3 对象键:files[].path 是虚拟路径,文件夹还缺
// 尾斜杠,直接当 marker 用会导致续列重复/死循环。经 getKey 转换:
// 去前导斜杠 + 文件夹补尾斜杠。
const last = result.files[result.files.length - 1]
marker = getKey(last.path, last.isFolder)
} else {
break
}
Expand Down
26 changes: 22 additions & 4 deletions src/backend/drivers/webdav/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export function pathEscape(p: string): string {
.join("/")
}

/** 反转义 XML 实体(&amp; 最后替换,避免二次解码) */
function unescapeXmlEntities(s: string): string {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
}

/**
* Robust WebDAV XML Multistatus parser.
* Handles diverse namespace prefixes (d:, D:, a:, xmlns="DAV:") and case variations.
Expand Down Expand Up @@ -105,7 +115,9 @@ export function parseMultistatusXml(
/<(?:[a-zA-Z0-9_-]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9_-]+:)?displayname>/i.exec(
propContent,
)
let displayName = dnMatch ? dnMatch[1].trim() : ""
// displayname 是 XML 文本节点:文件名含 & < > ' 时服务端返回的是实体
// (如 a&amp;b.txt),必须反转义,否则列表展示错误且后续 rename/move/get 404
let displayName = dnMatch ? unescapeXmlEntities(dnMatch[1].trim()) : ""

// Derive name from href if displayname is missing or is pure path
const cleanHref = decodedHref.replace(/\/+$/, "")
Expand Down Expand Up @@ -162,14 +174,20 @@ export function parseMultistatusXml(
}

// Check if this response represents the directory itself
// 注意:只能用精确匹配。旧实现 `normHref.endsWith(normTarget)` 有两个坑:
// 1) normTarget 为空串时 endsWith("") 恒真,multistatus 中第一个 <response>
// (若服务器把子项排在目录之前)会被误判为 self 而丢条目;
// 2) 子目录与父目录同名(/movies/movies)也会命中 endsWith 被吞。
// href 可能是完整 URL(http://host/dav/path),先剥离 scheme://host 再比较。
const normTarget = targetPath.replace(/\/+$/, "").toLowerCase()
const normHref = cleanHref.toLowerCase()
const normHref = cleanHref
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/, "")

if (
!selfItem &&
(normHref === normTarget ||
normHref.endsWith(normTarget) ||
(normTarget === "" && normHref === ""))
(normTarget === "" && (normHref === "" || normHref === "/")))
) {
selfItem = file
} else {
Expand Down
6 changes: 4 additions & 2 deletions src/backend/internal/archive/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ export async function extractZipEntry(
}
const ds = new DecompressionStream("deflate-raw")
const writer = ds.writable.getWriter()
writer.write(compressed)
writer.close()
// write/close 必须等待:否则流中途出错(坏数据等)会变成 unhandled rejection,
// 而不是在本次解压的 await 链路上传播,调用方拿到截断结果而无明确报错。
await writer.write(compressed)
await writer.close()
const out = await new Response(ds.readable).arrayBuffer()
return new Uint8Array(out)
}
Expand Down
58 changes: 51 additions & 7 deletions src/backend/internal/stream/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,60 @@ export interface RangeParams {
chunksize: number
}

// Parses the standard Range header
/**
* 解析标准 Range header(RFC 7233)。
* 支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`(末尾 N 字节);
* end 超出文件大小时按规范收敛到 fileSize-1。
* 非法 / 多段 / 不可满足的 Range 返回 null,调用方应回退为全量 200 响应。
*
* 此前的实现对 `bytes=-N`(部分播放器拖动进度条时发送)会解析出 start=NaN,
* 直接传给 fs.createReadStream 会同步抛 ERR_OUT_OF_RANGE 导致下载 500。
*/
export function parseRangeHeader(
rangeHeader: string,
fileSize: number,
): RangeParams {
const parts = rangeHeader.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunksize = end - start + 1
return { start, end, chunksize }
): RangeParams | null {
if (!rangeHeader || !Number.isFinite(fileSize) || fileSize <= 0) {
return null
}

const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/)
if (!match) {
return null
}

let start: number
let end: number

if (match[1] === "" && match[2] === "") {
// "bytes=-" 无法确定任何区间
return null
}

if (match[1] === "") {
// 后缀形式 "bytes=-N":取文件末尾 N 字节
const suffix = parseInt(match[2], 10)
if (isNaN(suffix) || suffix <= 0) {
return null
}
start = Math.max(0, fileSize - suffix)
end = fileSize - 1
} else {
start = parseInt(match[1], 10)
end = match[2] === "" ? fileSize - 1 : parseInt(match[2], 10)
if (isNaN(start) || isNaN(end)) {
return null
}
if (end >= fileSize) {
end = fileSize - 1 // 按规范收敛,而不是返回错误切片
}
}

if (start >= fileSize || start > end) {
return null
}

return { start, end, chunksize: end - start + 1 }
}

let fs: any = null
Expand Down
9 changes: 8 additions & 1 deletion src/backend/internal/upload/multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,14 @@ function intervalsOf(set: Set<number>): [number, number][] {

export function snapshot(s: MultipartSession): MultipartSnapshot {
const intervals = intervalsOf(s.received)
const receivedBytes = s.received.size * s.chunk_size
// received_bytes 必须按每个分片的实际字节数累加:末分片在 size 不整除
// chunk_size 时更小,直接用 received.size * chunk_size 会超算
//(例如 15MB 文件、10MB 分片:2 片全收后应为 15MB 而非 20MB)。
let receivedBytes = 0
for (const idx of s.received) {
const offset = idx * s.chunk_size
receivedBytes += Math.max(0, Math.min(s.chunk_size, s.size - offset))
}
// frontier:连续已收的最大 index + 1(驱动顺序写入进度)
let frontier = 0
for (let i = 0; i < s.total_chunks; i++) {
Expand Down
14 changes: 12 additions & 2 deletions src/backend/pkg/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,18 @@ function getCookie(c: Context, name: string): string | undefined {

const cookies = cookieHeader.split(";").map((c) => c.trim())
for (const cookie of cookies) {
const [key, value] = cookie.split("=")
if (key === name) return decodeURIComponent(value)
const eq = cookie.indexOf("=")
if (eq === -1) continue
const key = cookie.slice(0, eq).trim()
const rawValue = cookie.slice(eq + 1)
if (key !== name) continue
// 值含非法百分号序列(如 %zz)时 decodeURIComponent 会抛 URIError,
// 导致中间件 500;此时退回原值交由后续比对自然失败。
try {
return decodeURIComponent(rawValue)
} catch {
return rawValue
}
}
return undefined
}
Expand Down
Loading