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
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
1 change: 0 additions & 1 deletion src/backend/internal/model/db_cipher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
encryptConfigValue,
isSealedCiphertext,
resolveDbCipher,
type DbCipher,
} from "../../pkg/crypto"
import { readCipher } from "./store/backend"
import { memoryDriver } from "./store/driver/memory"
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 @@ -135,13 +135,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