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
4 changes: 4 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ MYSQL_URLS=
# 留空时自动使用当前请求的 origin(同一部署自调用);跨域或本地调试才需显式设置。
EO_KV_URLS=

# 插件 manifest_url 安装的可信 origin,例如 https://yourdomain.com。
# 留空禁用 URL 安装;目标 URL 必须与该 origin 完全一致。
PLUGIN_MANIFEST_ORIGIN=

# Cloudflare KV REST API 凭据(DB_DRIVER=cfkv 时三项均必填)。
# 账户 ID
CF_ACCOUNT=
Expand Down
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ MYSQL_URLS=
# 留空时自动使用当前请求的 origin(同一部署自调用);跨域或本地调试才需显式设置。
EO_KV_URLS=

# 插件 manifest_url 安装的可信 origin,例如 https://yourdomain.com。
# 留空禁用 URL 安装;目标 URL 必须与该 origin 完全一致。
PLUGIN_MANIFEST_ORIGIN=

# Cloudflare KV REST API 凭据(DB_DRIVER=cfkv 时三项均必填)。
# 账户 ID
CF_ACCOUNT=
Expand Down
246 changes: 124 additions & 122 deletions cloud-functions/[[default]].js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
},
"DB_CIPHER": {
"description": "At-rest cipher for sensitive fields (drive credentials, 2FA secrets, password hashes): `none` (default, no encryption), `aes-256-gcm` (HKDF-derived AES-256-GCM key — the `enc:v2:` envelope written by existing encrypted deployments, recommended), `aes-256-gcm-pbkdf2` (legacy `enc:v1:` envelope, PBKDF2 per field, slow), `aes-256-cbc-hmac` (`enc:v3:`), `chacha20-poly1305` (`enc:v4:`, RFC 8439, pure-JS implementation), `des-cbc-hmac` / `3des-cbc-hmac` (`enc:v5:`/`enc:v6:`, compatibility only — single DES is 56-bit and 3DES is deprecated, do not use for real data). Ciphertexts carry an `enc:vN:` prefix and are decrypted by prefix on read, so switching back to `none` (or changing algorithms) never makes existing data unreadable — it only changes how newly written data is stored (legacy ciphertext is migrated to plaintext on the next save). Key derivation is cached per isolate, and unchanged fields are not re-encrypted on save. This setting does NOT affect the shared secret: with `none`, a secret is still generated and persisted during setup when JWT_SECRET is not provided, because JWT signing needs it."
},
"PLUGIN_MANIFEST_ORIGIN": {
"description": "Optional trusted origin for installing plugin manifests by URL. The URL must exactly match this origin; redirects and oversized responses are rejected. Leave empty to disable manifest_url installs."
}
}
},
Expand Down
13 changes: 12 additions & 1 deletion src/backend/durable-objects/OpenListDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
* 通过 RPC 调用(stub.kvGet / sqlQuery 等),每个实例用 `idFromName` 定位到
* 固定实例,保证数据持久在同一 DO 实例。
*/
import { D1_SCHEMA, KV_SCHEMA_SQLITE } from "../internal/model/store/schema"
import {
buildAddColumnDdl,
buildTableInfoDdl,
D1_SCHEMA,
KV_SCHEMA_SQLITE,
} from "../internal/model/store/schema"

export class OpenListDB {
private state: any
Expand All @@ -32,6 +37,12 @@ export class OpenListDB {
for (const ddl of [...KV_SCHEMA_SQLITE, ...D1_SCHEMA]) {
this.sql.exec(ddl)
}
const columns = this.sql
.exec(buildTableInfoDdl("plugins"))
.toArray()
if (!columns.some((column: any) => column.name === "manifest")) {
this.sql.exec(buildAddColumnDdl("plugins", "manifest", "sqlite"))
}
}

async init(): Promise<void> {
Expand Down
38 changes: 29 additions & 9 deletions src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2003,10 +2003,15 @@ async function unsealDb(
}
}

export interface SaveDbOptions {
force?: boolean
publishOnSuccess?: boolean
}

export const saveDb = async (
data: any,
envCtx?: any,
options?: { force?: boolean },
options?: SaveDbOptions,
): Promise<boolean> => {
if (envCtx) {
globalEnvCtx = envCtx
Expand Down Expand Up @@ -2043,14 +2048,14 @@ export const saveDb = async (
return false
}

memoryDb = data
dbWriteBlocked = false
// Refresh the request cache so any getDb() later in this request observes
// the write rather than a pre-write snapshot.
// 无参调用会以 globalEnvCtx 为键命中缓存,因此这里也同步刷新该键,
// 否则「写后读」在无参路径上可能读到 TTL 内的旧快照。
const publishOnSuccess = options?.publishOnSuccess === true
const cacheKey = envCtx || resolveNoArgKey()
if (cacheKey) dbCache.set(cacheKey, { ts: Date.now(), db: data })
const publish = () => {
memoryDb = data
dbWriteBlocked = false
if (cacheKey) dbCache.set(cacheKey, { ts: Date.now(), db: data })
}
if (!publishOnSuccess) publish()

// `activeEnv` 已在本函数开头解析(写前守卫也依赖它),这里只需通过可注入的
// storeBackendLoader 取后端,便于测试统计 load/save 次数。
Expand Down Expand Up @@ -2087,7 +2092,13 @@ export const saveDb = async (
console.log(
`[DB] saveDb: sealed data size=${JSON.stringify(sealed).length} bytes`,
)
await backend.save(sealed, activeEnv)
const saved = await backend.save(sealed, activeEnv)
if (!saved) {
console.warn(
`[DB] saveDb FAILED: backend=${backend.name} returned false`,
)
return false
}
} catch (err: any) {
console.error(
`[DB] saveDb FAILED: backend=${backend.name}, error=${err?.message || err}`,
Expand All @@ -2098,6 +2109,7 @@ export const saveDb = async (
)
}

if (publishOnSuccess) publish()
console.log(
`[DB] Successfully persisted ${data.storages?.length || 0} storages to ${backend.name}`,
)
Expand All @@ -2109,6 +2121,14 @@ export const saveDb = async (
return true
}

export async function reloadDb(envCtx: any): Promise<any> {
if (envCtx && typeof envCtx === "object") {
dbCache.delete(envCtx)
dbInflight.delete(envCtx)
}
return loadDb(envCtx)
}

export async function resolvePath(virtualPath: string, envCtx?: any) {
const db = await getDb(envCtx)

Expand Down
37 changes: 37 additions & 0 deletions src/backend/internal/model/db_cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,43 @@ test("getDb: 五个无参 getter 复用同一份缓存快照", async () => {
assert.ok(Array.isArray(plugins))
})

test("saveDb publishOnSuccess keeps the old snapshot after failure", async () => {
__resetDbCacheForTest()
const { backend } = createCountingBackend(SAMPLE)
const originalSave = backend.save
let shouldFail = true
backend.save = async (next: any) => {
if (shouldFail) throw new Error("write failed")
return originalSave(next)
}
__setStoreBackendLoaderForTest(async () => backend)
const env = { DB_DRIVER: "counting" }
const before = await getDb(env)
const candidate = {
...before,
settings: [{ key: "site_title", value: "Changed" }],
}

await assert.rejects(
saveDb(candidate, env, { publishOnSuccess: true } as any),
)
assert.equal(await getDb(env), before)

backend.save = async () => false
assert.equal(
await saveDb(candidate, env, { publishOnSuccess: true } as any),
false,
)
assert.equal(await getDb(env), before)

backend.save = originalSave
assert.equal(
await saveDb(candidate, env, { publishOnSuccess: true } as any),
true,
)
assert.equal(await getDb(env), candidate)
})

test("getDb: TTL 过期后允许重新加载(缓存不是永久固化)", async () => {
__resetDbCacheForTest()
const { backend, stats } = createCountingBackend(SAMPLE)
Expand Down
143 changes: 143 additions & 0 deletions src/backend/internal/model/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import {
applyPluginManifest,
parsePluginManifest,
toPublicPlugin,
} from "./plugin"

const minimal = {
apiVersion: "v1",
id: "com.example.docs",
version: "1.0.0",
displayName: "Docs",
}

test("plugin manifest v1 applies defaults", () => {
assert.deepEqual(parsePluginManifest(minimal), {
...minimal,
description: "",
capabilities: [],
settingsSchema: {},
})
})

test("plugin manifest v1 rejects unknown fields and unsupported versions", () => {
assert.throws(() => parsePluginManifest({ ...minimal, extra: true }))
assert.throws(() => parsePluginManifest({ ...minimal, apiVersion: "v2" }))
})

test("plugin manifest v1 validates identity and capability names", () => {
assert.throws(() => parsePluginManifest({ ...minimal, id: "Example" }))
assert.throws(() => parsePluginManifest({ ...minimal, version: "" }))
assert.throws(() =>
parsePluginManifest({
...minimal,
capabilities: ["files.read", "files.read"],
}),
)
assert.throws(() =>
parsePluginManifest({ ...minimal, capabilities: ["Files.Read"] }),
)
})

test("plugin manifest v1 bounds nested JSON", () => {
let nested: any = { value: true }
for (let i = 0; i < 20; i++) nested = { nested }
assert.throws(() =>
parsePluginManifest({ ...minimal, settingsSchema: nested }),
)
assert.throws(() =>
parsePluginManifest({
...minimal,
entry: JSON.parse('{"constructor":{"prototype":{}}}'),
}),
)
assert.throws(() =>
parsePluginManifest({
...minimal,
entry: { value: "x".repeat(70_000) },
}),
)
assert.throws(() =>
parsePluginManifest({
...minimal,
settingsSchema: Object.fromEntries(
Array.from({ length: 5 }, (_, index) => [
`field${index}`,
"x".repeat(8_000),
]),
),
entry: Object.fromEntries(
Array.from({ length: 5 }, (_, index) => [
`field${index}`,
"x".repeat(8_000),
]),
),
}),
)
})

test("plugin manifest preserves bounded opaque entry data", () => {
const entry = { module: "index.js", integrity: "sha256-abc" }
assert.deepEqual(parsePluginManifest({ ...minimal, entry }).entry, entry)
})

test("applying a manifest preserves legacy fields and synchronizes projections", () => {
const plugin = {
id: minimal.id,
name: "Old name",
version: "0.0.1",
description: "Old description",
enabled: false,
config_values: { token: "secret" },
}
const updated = applyPluginManifest(plugin, {
...minimal,
displayName: "New name",
version: "2.0.0",
description: "New description",
})
assert.equal(updated.name, "New name")
assert.equal(updated.version, "2.0.0")
assert.equal(updated.description, "New description")
assert.equal(updated.enabled, false)
assert.deepEqual(updated.config_values, { token: "secret" })
assert.equal(updated.manifest?.displayName, "New name")
assert.throws(() => applyPluginManifest(plugin, { ...minimal, id: "other" }))
})

test("public plugin projection keeps legacy rows and hides manifest internals", () => {
assert.deepEqual(
toPublicPlugin({
id: "legacy",
script_content: "raw",
config_values: { token: "secret" },
}),
{ id: "legacy" },
)

const projected = toPublicPlugin(
applyPluginManifest(
{ id: minimal.id, enabled: true, config_values: { token: "secret" } },
{
...minimal,
capabilities: ["files_read"],
settingsSchema: { type: "object" },
entry: { module: "index.js" },
},
),
)
assert.deepEqual(projected.manifest, {
apiVersion: "v1",
id: minimal.id,
version: minimal.version,
displayName: minimal.displayName,
description: "",
capabilities: ["files_read"],
})
assert.equal("entry" in projected.manifest, false)
assert.equal("settingsSchema" in projected.manifest, false)
assert.equal("config_values" in projected, false)
assert.equal("script_content" in projected, false)
})
Loading