Skip to content
12 changes: 10 additions & 2 deletions src/backend/drivers/github_releases/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@ function proxyOf(additionGhProxy: string | undefined, url: string): string {
return (client as unknown as { proxy: (u: string) => string }).proxy(url)
}

test("gh_proxy 已配置时为 http(s) 下载链接加代理前缀", () => {
test("gh_proxy 已配置时替换 https://github.com 前缀(对齐 Go Link)", () => {
const url = "https://github.com/example/repo/releases/download/v1.0.0/app.zip"
// 文档推荐写法(meta.go 的 help):值本身包含 https://github.com,
// 替换后等价于「代理前缀 + 原始链接」
assert.equal(
proxyOf("https://gh-proxy.com/https://github.com", url),
"https://gh-proxy.com/" + url,
)
// 裸域名写法:按 Go strings.Replace 的语义,只把 https://github.com 这一段整体换掉
// (此前断言写成 proxy + 完整 url,与实现/Go 语义不符)
assert.equal(
proxyOf("https://mirror.example.com", url),
"https://mirror.example.com" + url,
"https://mirror.example.com" + url.slice("https://github.com".length),
)
})

Expand Down
17 changes: 17 additions & 0 deletions src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2066,6 +2066,15 @@ export const saveDb = async (
return false
}

// 记录写入前的内存状态,供持久化失败时回滚。
//
// 稳定性不变量:**持久化失败后,失败的修改不得在内存/请求缓存中生效**。
// 历史实现先更新 memoryDb 与 dbCache、再尝试落盘,失败只抛错不回滚。
// 结果是「接口报 500,但当前实例的角色/权限/设置已按未保存的内容生效」——
// 调用方以为失败、系统却按新值运行(实测:保存失败后 getDb() 仍读到新 role)。
const previousDb = memoryDb
const previousTrusted = dbTrusted

memoryDb = data
dbWriteBlocked = false
// Refresh the request cache so any getDb() later in this request observes
Expand Down Expand Up @@ -2112,6 +2121,14 @@ export const saveDb = async (
)
await backend.save(sealed, activeEnv)
} catch (err: any) {
// 回滚内存与请求缓存:持久化没成功,就不能让本次修改在本实例内可见,
// 否则会出现「报错但已生效」的不一致状态。
memoryDb = previousDb
dbTrusted = previousTrusted
if (cacheKey) {
if (previousDb) dbCache.set(cacheKey, { ts: Date.now(), db: previousDb })
else dbCache.delete(cacheKey)
}
console.error(
`[DB] saveDb FAILED: backend=${backend.name}, error=${err?.message || err}`,
`stack=${err?.stack?.substring(0, 500) || ""}`,
Expand Down
40 changes: 36 additions & 4 deletions src/backend/internal/model/store/driver/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,45 @@ export const d1Driver: Driver = {
if (!db) throw new Error("D1 binding not found")

await ensureSchema(db, env)

// D1 batch 单次语句数上限约 100,分批提交

// D1 batch 单次语句数上限约 100,必须分批提交。
//
// 语义边界(重要,不要误以为这里提供整体事务):**片内原子,跨片不原子**。
// D1 没有跨 batch 调用的事务,因此语句数 >100 时,后续片失败**不会**回滚
// 已提交的片。要让整体操作可恢复,依赖两点:
// ① 上层 format/sql 生成的是幂等 UPSERT + 末位 DELETE NOT IN,整体重跑可收敛;
// ② 这里对**单片的瞬时失败**做有限重试,避免一次网络抖动就留下半成品。
// 重试仍失败时,错误信息带上「已提交片数」,供调用方判断是否需要整体重跑。
const BATCH = 100
const MAX_RETRY = 2
const stmts = statements.map((s) => db.prepare(s.sql).bind(...s.params))


let committed = 0
for (let i = 0; i < stmts.length; i += BATCH) {
await db.batch(stmts.slice(i, i + BATCH))
const chunk = stmts.slice(i, i + BATCH)
let lastErr: any = null
for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {
try {
await db.batch(chunk)
lastErr = null
break
} catch (err) {
lastErr = err
if (attempt < MAX_RETRY) {
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)))
}
}
}
if (lastErr) {
throw new Error(
`D1 batch failed at chunk ${Math.floor(i / BATCH) + 1} ` +
`(statements ${i}-${i + chunk.length - 1} of ${stmts.length}); ` +
`${committed} statement(s) were already committed by earlier chunks and ` +
`D1 cannot roll them back across batches. The save is idempotent, so ` +
`retrying the whole operation converges. Cause: ${lastErr?.message || lastErr}`,
)
}
committed += chunk.length
}
},

Expand Down
31 changes: 21 additions & 10 deletions src/backend/internal/model/store/format/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,16 @@ export const keyFormat: FormatAdapter = {
},

async save(data: any, driver: Driver, env?: any): Promise<boolean> {
// 清空旧数据
for (const table of TABLE_NAMES) {
const prefix = tablePrefix(table)
const keys = await driver.list(prefix, env)
for (const key of keys) {
await driver.delete(key, env)
}
}

// 写入新数据
// 先写新数据,**最后**清理陈旧键。
//
// 稳定性不变量:save 失败不得让「原有数据整体消失」。
// 历史实现是「先删光所有前缀键,再写新数据」:一旦写入阶段失败(网络抖动、
// 配额、实例被杀),旧记录已被删除、新记录尚未写入,数据库直接变成空表。
// 故障注入实测:users 从 1 条变为 0 条 —— 这是数据丢失,不是「保存失败」。
//
// 改为先写后删后,失败时的最坏情况是**残留**旧键(可被下一次成功保存清理),
// 而不会丢数据;正常成功路径的最终状态与原来完全一致。
const liveKeys = new Set<string>()
for (const table of TABLE_NAMES) {
const keyCol = TABLE_KEY[table]
const records = data?.[table] || []
Expand All @@ -85,11 +85,22 @@ export const keyFormat: FormatAdapter = {
if (!id) continue

const key = fullKeyOf(table, id)
liveKeys.add(key)
const value = JSON.stringify(record)
await driver.put(key, value, env)
}
}

// 清理本快照中已不存在的键(删除的实体 / 改名的键)
for (const table of TABLE_NAMES) {
const prefix = tablePrefix(table)
const keys = await driver.list(prefix, env)
for (const key of keys) {
if (liveKeys.has(key)) continue
await driver.delete(key, env)
}
}

// 标记已初始化
await driver.put(INIT_MARK, String(Date.now()), env)
return true
Expand Down
18 changes: 16 additions & 2 deletions src/backend/internal/model/store/format/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@ export const mapFormat: FormatAdapter = {
try {
return JSON.parse(raw)
} catch (err) {
console.error("[mapFormat] Failed to parse JSON:", err)
return null
// 关键:配置**损坏**绝不能等同于「没有数据」。
//
// 历史行为是打印一条日志后返回 null。但 loadDb() 用同一个 null 表示
// 「后端为空(全新部署)」,两者无法区分,于是损坏的配置会被当成全新部署:
// dbTrusted=false 且 getDbLoadError()=null → /api/public/init/setup 放行
// → 无登录即可创建新管理员并把空壳写回,**覆盖真实配置**。
// (实测:把持久化值改成非法 JSON 后,公开的初始化接口返回 200。)
//
// 这里改为抛错:loadDb() 会记录 dbLastLoadError 并保持 dbTrusted=false,
// 初始化接口据此拒绝(返回 500 且不写入)。真正的空库不经过此分支,
// 仍可正常初始化。
throw new Error(
`[mapFormat] persisted config is not valid JSON; refusing to treat it as an ` +
`empty database. Restore or explicitly remove the "${CONFIG_KEY}" entry. ` +
`Cause: ${(err as Error)?.message || String(err)}`,
)
}
},

Expand Down
37 changes: 35 additions & 2 deletions src/backend/internal/op/search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { listItems } from "./storage"
import { FileItem } from "../driver/base"
import { canAccess, getNearestMeta } from "../../pkg/meta"
import type { Meta } from "../../pkg/meta"
import type { UserPermissionObj } from "../../pkg/permission"

export interface SearchOptions {
parent?: string
Expand All @@ -12,6 +15,14 @@ export interface SearchOptions {
max_depth?: number
/** Max items to collect before truncating (default 500) */
max_results?: number
/**
* 访问者。提供时对每个结果做 meta 判定过滤(密码保护 + read_users 白名单),
* 对齐 Go handles.Search 传给 SearchFiltered 的 filter 闭包;不提供时
* (内部调用 / 管理员构建索引)不做过滤,与历史行为一致。
*/
user?: UserPermissionObj | null
/** 目录访问密码(meta.password) */
password?: string
}

export interface SearchResultItem extends FileItem {
Expand Down Expand Up @@ -43,6 +54,7 @@ export async function search(
const maxResults = Math.min(2000, Math.max(1, opts.max_results ?? 500))

const matches: SearchResultItem[] = []
const enforceAcl = opts.user !== undefined

async function walk(dirPath: string, depth: number) {
if (depth > maxDepth || matches.length >= maxResults) return
Expand All @@ -56,9 +68,32 @@ export async function search(
return
}

// 与 Go 一致:meta 取「父目录」的最近 meta,再对子项自身路径做判定
let dirMeta: Meta | null = null
if (enforceAcl) {
try {
dirMeta = await getNearestMeta(dirPath, env)
} catch {
dirMeta = null
}
}

for (const item of items) {
if (matches.length >= maxResults) break

const subPath =
dirPath === "/" ? `/${item.name}` : `${dirPath}/${item.name}`

// meta 访问控制:密码保护 / read_users 白名单覆盖的内容不出现在搜索
// 结果里。历史缺陷:搜索完全跳过 meta 判定——实测直连被密码保护而
// 被拒的目录,其文件名/大小仍能被搜出来(信息泄露)。
if (
enforceAcl &&
!canAccess(opts.user!, dirMeta, subPath, opts.password || "")
) {
continue
}

const nameMatch = !keyword || item.name.toLowerCase().includes(keyword)
const isDir = !!item.is_dir
// 挂载点虚拟文件夹(storage.ts 合并虚拟子挂载时 sign 为空),
Expand All @@ -81,8 +116,6 @@ export async function search(
}

if (isDir) {
const subPath =
dirPath === "/" ? `/${item.name}` : `${dirPath}/${item.name}`
await walk(subPath, depth + 1)
}
}
Expand Down
21 changes: 20 additions & 1 deletion src/backend/internal/upload/multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export interface MultipartSession {
partMd5s: (string | undefined)[]
/** 驱动名 + 存储引用,用于 chunk/complete 时重新 resolve 驱动 */
storage_driver: string
/**
* 会话所有者的用户 id。
*
* 安全不变量:分片会话是**用户私有状态**,续传/完成/查询都必须校验所有者。
* 历史缺陷:会话只按 `path + size` 复用,不含用户维度,导致不同用户(即使
* base_path 不同)会命中同一个会话并互相续传(实测:两个不同根目录的用户
* 拿到同一个 upload_id,且 resumed=true)。
*/
owner_id?: number
/** 创建会话时的用户根目录,用于校验会话未被跨根复用 */
owner_root?: string
created_at: number
error?: string
}
Expand Down Expand Up @@ -119,12 +130,20 @@ export function getSession(uploadId: string): MultipartSession | undefined {
return sessions.get(uploadId)
}

/** 查找同 path+size 的未完成会话(用于断点续传) */
/**
* 查找同 owner + path + size 的未完成会话(用于断点续传)。
*
* `ownerId` 必填:会话必须按所有者隔离,否则不同用户会共享同一上传会话。
* 未绑定所有者的历史会话(本改动之前创建的)不再被复用,避免跨用户命中。
*/
export function findReceivingSession(
path: string,
size: number,
ownerId?: number,
): MultipartSession | undefined {
if (ownerId === undefined) return undefined
for (const s of sessions.values()) {
if (s.owner_id !== ownerId) continue
if (
s.path === path &&
s.size === size &&
Expand Down
Loading
Loading