diff --git a/.dev.vars.example b/.dev.vars.example index af758754..5edbcabb 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -76,6 +76,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= diff --git a/.env.example b/.env.example index d8159d1c..f2d7e26e 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,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= diff --git a/package.json b/package.json index 93d16da6..34f4aefb 100644 --- a/package.json +++ b/package.json @@ -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." } } }, diff --git a/src/backend/durable-objects/OpenListDB.ts b/src/backend/durable-objects/OpenListDB.ts index 007cd077..5fd34b28 100644 --- a/src/backend/durable-objects/OpenListDB.ts +++ b/src/backend/durable-objects/OpenListDB.ts @@ -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 @@ -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 { diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 4c3ca665..815fc137 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -2026,10 +2026,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 => { if (envCtx) { globalEnvCtx = envCtx @@ -2066,14 +2071,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 次数。 @@ -2110,7 +2115,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}`, @@ -2121,6 +2132,7 @@ export const saveDb = async ( ) } + if (publishOnSuccess) publish() console.log( `[DB] Successfully persisted ${data.storages?.length || 0} storages to ${backend.name}`, ) @@ -2132,6 +2144,14 @@ export const saveDb = async ( return true } +export async function reloadDb(envCtx: any): Promise { + 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) diff --git a/src/backend/internal/model/db_cache.test.ts b/src/backend/internal/model/db_cache.test.ts index a56c7c51..756a96c7 100644 --- a/src/backend/internal/model/db_cache.test.ts +++ b/src/backend/internal/model/db_cache.test.ts @@ -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) diff --git a/src/backend/internal/model/plugin.test.ts b/src/backend/internal/model/plugin.test.ts new file mode 100644 index 00000000..cbc598df --- /dev/null +++ b/src/backend/internal/model/plugin.test.ts @@ -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) +}) diff --git a/src/backend/internal/model/plugin.ts b/src/backend/internal/model/plugin.ts new file mode 100644 index 00000000..b3620b25 --- /dev/null +++ b/src/backend/internal/model/plugin.ts @@ -0,0 +1,226 @@ +import { z } from "zod" + +export const PLUGIN_MANIFEST_API_VERSION = "v1" +export const PLUGIN_MANIFEST_MAX_BYTES = 65_535 +export const PLUGIN_MANIFEST_MAX_DEPTH = 12 + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ +const CONTROL_CHARACTERS_WITH_LINE_BREAKS = + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ +const FORBIDDEN_JSON_KEYS = new Set(["__proto__", "prototype", "constructor"]) + +const identifierSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/) + +const capabilitySchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/) + +const manifestSchema = z + .object({ + apiVersion: z.literal(PLUGIN_MANIFEST_API_VERSION), + id: identifierSchema, + version: z + .string() + .trim() + .min(1) + .max(64) + .refine((value) => !CONTROL_CHARACTERS.test(value)), + displayName: z + .string() + .trim() + .min(1) + .max(128) + .refine((value) => !CONTROL_CHARACTERS.test(value)), + description: z + .string() + .max(2048) + .default("") + .refine((value) => !CONTROL_CHARACTERS_WITH_LINE_BREAKS.test(value)), + capabilities: z + .array(capabilitySchema) + .max(32) + .default([]) + .refine((values) => new Set(values).size === values.length), + settingsSchema: z.record(z.unknown()).default({}), + entry: z.record(z.unknown()).optional(), + }) + .strict() + +export type PluginManifest = z.infer +export type PluginRow = Record & { + id: string + manifest?: PluginManifest +} + +export class PluginManifestError extends Error { + readonly code = "INVALID_PLUGIN_MANIFEST" + + constructor(message: string) { + super(message) + this.name = "PluginManifestError" + } +} + +function assertBoundedJson(value: unknown, label: string): void { + const active = new WeakSet() + let nodes = 0 + + const walk = (current: unknown, depth: number): void => { + nodes++ + if (nodes > 2_048) { + throw new PluginManifestError(`${label} contains too many values`) + } + if (depth > PLUGIN_MANIFEST_MAX_DEPTH) { + throw new PluginManifestError( + `${label} exceeds the maximum nesting depth`, + ) + } + if (current === null || typeof current === "boolean") return + if (typeof current === "number") { + if (!Number.isFinite(current)) { + throw new PluginManifestError(`${label} contains a non-finite number`) + } + return + } + if (typeof current === "string") { + if (current.length > 8_192) { + throw new PluginManifestError(`${label} contains an oversized string`) + } + return + } + if (typeof current !== "object") { + throw new PluginManifestError(`${label} contains a non-JSON value`) + } + if (active.has(current)) { + throw new PluginManifestError(`${label} contains a circular reference`) + } + active.add(current) + if (Array.isArray(current)) { + if (current.length > 256) { + throw new PluginManifestError(`${label} contains an oversized array`) + } + for (const item of current) walk(item, depth + 1) + } else { + const prototype = Object.getPrototypeOf(current) + if (prototype !== Object.prototype && prototype !== null) { + throw new PluginManifestError( + `${label} must contain plain JSON objects`, + ) + } + const keys = Object.keys(current) + if (keys.length > 256) { + throw new PluginManifestError(`${label} contains too many properties`) + } + for (const key of keys) { + if (FORBIDDEN_JSON_KEYS.has(key)) { + throw new PluginManifestError( + `${label} contains a forbidden property`, + ) + } + walk((current as Record)[key], depth + 1) + } + } + active.delete(current) + } + + walk(value, 0) + let bytes: number + try { + bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength + } catch { + throw new PluginManifestError(`${label} must be JSON serializable`) + } + if (bytes > PLUGIN_MANIFEST_MAX_BYTES) { + throw new PluginManifestError(`${label} exceeds the maximum encoded size`) + } +} + +export function parsePluginManifest(input: unknown): PluginManifest { + const parsed = manifestSchema.safeParse(input) + if (!parsed.success) { + throw new PluginManifestError( + parsed.error.issues + .map( + (issue) => `${issue.path.join(".") || "manifest"}: ${issue.message}`, + ) + .join("; "), + ) + } + assertBoundedJson(parsed.data, "manifest") + return parsed.data +} + +export function applyPluginManifest( + plugin: Record, + input: unknown, +): PluginRow { + const manifest = parsePluginManifest(input) + if (plugin.id !== manifest.id) { + throw new PluginManifestError("manifest.id must match the plugin id") + } + return { + ...plugin, + id: manifest.id, + name: manifest.displayName, + version: manifest.version, + description: manifest.description, + manifest, + } +} + +const PUBLIC_PLUGIN_FIELDS = [ + "id", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "icon", + "type", + "enabled", + "high_privilege", + "permissions", + "is_builtin", + "tags", + "created_at", + "updated_at", +] as const + +function publicLegacyPlugin(plugin: Record): Record { + const result: Record = {} + for (const field of PUBLIC_PLUGIN_FIELDS) { + if (plugin[field] !== undefined) result[field] = plugin[field] + } + return result +} + +export function toPublicPlugin( + plugin: Record, +): Record { + const legacy = publicLegacyPlugin(plugin) + if (plugin.manifest === undefined || plugin.manifest === null) return legacy + let parsed: PluginManifest + try { + parsed = parsePluginManifest(plugin.manifest) + } catch { + return legacy + } + return { + ...legacy, + manifest: { + apiVersion: parsed.apiVersion, + id: parsed.id, + version: parsed.version, + displayName: parsed.displayName, + description: parsed.description, + capabilities: parsed.capabilities, + }, + } +} diff --git a/src/backend/internal/model/store/driver/d1.ts b/src/backend/internal/model/store/driver/d1.ts index 5fb7070c..b0e8b691 100644 --- a/src/backend/internal/model/store/driver/d1.ts +++ b/src/backend/internal/model/store/driver/d1.ts @@ -6,7 +6,12 @@ * - OPENLIST_DB (别名) */ import type { Driver } from "../types" -import { buildDdl, KV_SCHEMA_SQLITE } from "../schema" +import { + buildAddColumnDdl, + buildDdl, + buildTableInfoDdl, + KV_SCHEMA_SQLITE, +} from "../schema" /** * 判断对象是否具备 D1 绑定接口形态。 @@ -42,15 +47,39 @@ function getD1(env?: any): any | null { return null } -const d1Inited = new WeakMap() +async function hasPluginManifestColumn(db: any, env?: any): Promise { + const columns = await db + .prepare(buildTableInfoDdl("plugins", env)) + .all() + return (columns.results || []).some( + (column: any) => column.name === "manifest", + ) +} -async function ensureSchema(db: any, env?: any): Promise { - if (d1Inited.get(db)) return - // KV 表(map/key 格式)+ 列式表(sql 格式)一并创建 - for (const ddl of [...KV_SCHEMA_SQLITE, ...buildDdl("sqlite", env)]) { - await db.prepare(ddl).run() - } - d1Inited.set(db, true) +const d1SchemaInit = new WeakMap>() + +function ensureSchema(db: any, env?: any): Promise { + const existing = d1SchemaInit.get(db) + if (existing) return existing + const task = (async () => { + for (const ddl of [...KV_SCHEMA_SQLITE, ...buildDdl("sqlite", env)]) { + await db.prepare(ddl).run() + } + if (!(await hasPluginManifestColumn(db, env))) { + try { + await db + .prepare(buildAddColumnDdl("plugins", "manifest", "sqlite", env)) + .run() + } catch (error) { + if (!(await hasPluginManifestColumn(db, env))) throw error + } + } + })() + d1SchemaInit.set(db, task) + void task.catch(() => { + if (d1SchemaInit.get(db) === task) d1SchemaInit.delete(db) + }) + return task } export const d1Driver: Driver = { @@ -62,7 +91,7 @@ export const d1Driver: Driver = { async init(env?: any): Promise { const db = getD1(env) - if (db) await ensureSchema(db) + if (db) await ensureSchema(db, env) }, async get(key: string, env?: any): Promise { @@ -135,14 +164,10 @@ export const d1Driver: Driver = { if (!db) throw new Error("D1 binding not found") await ensureSchema(db, env) - - // D1 batch 单次语句数上限约 100,分批提交 - const BATCH = 100 - const stmts = statements.map((s) => db.prepare(s.sql).bind(...s.params)) - - for (let i = 0; i < stmts.length; i += BATCH) { - await db.batch(stmts.slice(i, i + BATCH)) - } + const statementsForBatch = statements.map((statement) => + db.prepare(statement.sql).bind(...statement.params), + ) + await db.batch(statementsForBatch) }, async health(env?: any): Promise { diff --git a/src/backend/internal/model/store/driver/mysql.ts b/src/backend/internal/model/store/driver/mysql.ts index 73604457..63325615 100644 --- a/src/backend/internal/model/store/driver/mysql.ts +++ b/src/backend/internal/model/store/driver/mysql.ts @@ -8,7 +8,12 @@ * - MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASS, MYSQL_NAME */ import type { Driver } from "../types" -import { buildDdl, getTablePrefix, KV_SCHEMA_MYSQL } from "../schema" +import { + buildAddColumnDdl, + buildDdl, + KV_SCHEMA_MYSQL, + tableSqlName, +} from "../schema" function isNode(): boolean { return typeof process !== "undefined" && process.release?.name === "node" @@ -32,33 +37,70 @@ function getMysqlConfig(env: any): any | null { let _pool: any = null let _poolKey: string | null = null +let _poolPending: { key: string; promise: Promise } | null = null async function getPool(env: any): Promise { const config = getMysqlConfig(env) if (!config) return null const key = JSON.stringify(config) if (_pool && _poolKey === key) return _pool - - // 动态 import,避免打包到 Workers - const specifier = "mysql2/promise" - const { createPool } = await import(specifier) - _pool = createPool(config) - _poolKey = key - return _pool + if (_poolPending?.key === key) return _poolPending.promise + + const promise = (async () => { + const specifier = "mysql2/promise" + const { createPool } = await import(specifier) + const pool = createPool(config) + _pool = pool + _poolKey = key + return pool + })() + _poolPending = { key, promise } + try { + return await promise + } finally { + if (_poolPending?.promise === promise) _poolPending = null + } } -let _schemaInitedPrefix: string | null = null +async function hasPluginManifestColumn( + pool: any, + env?: any, +): Promise { + const [columns]: any[] = await pool.query( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + [tableSqlName("plugins", env), "manifest"], + ) + return Array.isArray(columns) && columns.length > 0 +} -async function ensureSchema(pool: any, env?: any): Promise { - const prefix = getTablePrefix(env) - if (_schemaInitedPrefix === prefix) return - // KV 表(map/key 格式)+ 列式表(sql 格式)一并创建 - for (const ddl of [...KV_SCHEMA_MYSQL, ...buildDdl("mysql", env)]) { - await pool.query(ddl) - } - _schemaInitedPrefix = prefix +const mysqlSchemaInit = new WeakMap>() + +function ensureSchema(pool: any, env?: any): Promise { + const existing = mysqlSchemaInit.get(pool) + if (existing) return existing + const task = (async () => { + for (const ddl of [...KV_SCHEMA_MYSQL, ...buildDdl("mysql", env)]) { + await pool.query(ddl) + } + if (!(await hasPluginManifestColumn(pool, env))) { + try { + await pool.query( + buildAddColumnDdl("plugins", "manifest", "mysql", env), + ) + } catch (error) { + if (!(await hasPluginManifestColumn(pool, env))) throw error + } + } + })() + mysqlSchemaInit.set(pool, task) + void task.catch(() => { + if (mysqlSchemaInit.get(pool) === task) mysqlSchemaInit.delete(pool) + }) + return task } +export const __ensureMysqlSchemaForTest = ensureSchema + export const mysqlDriver: Driver = { name: "mysql", @@ -69,7 +111,7 @@ export const mysqlDriver: Driver = { async init(env?: any): Promise { const pool = await getPool(env) - if (pool) await ensureSchema(pool) + if (pool) await ensureSchema(pool, env) }, async get(key: string, env?: any): Promise { diff --git a/src/backend/internal/model/store/schema.ts b/src/backend/internal/model/store/schema.ts index b5ce1a0d..4827d375 100644 --- a/src/backend/internal/model/store/schema.ts +++ b/src/backend/internal/model/store/schema.ts @@ -232,6 +232,7 @@ export const TABLES: Record = { { name: "id", type: "string", pk: true }, { name: "name", type: "string", nullable: true }, { name: "version", type: "string", nullable: true }, + { name: "manifest", type: "json", nullable: true }, { name: "description", type: "string", nullable: true }, { name: "author", type: "string", nullable: true }, { name: "homepage", type: "string", nullable: true }, @@ -417,6 +418,16 @@ function sqlType(col: ColumnDef, dialect: "sqlite" | "mysql"): string { } } +function ddlColumnType( + col: ColumnDef, + dialect: "sqlite" | "mysql", +): string { + if (dialect === "mysql" && (col.pk || col.unique) && col.type === "string") { + return "VARCHAR(255)" + } + return sqlType(col, dialect) +} + /** 列标识符统一加反引号(SQLite 与 MySQL 均支持)。 */ function quote(name: string): string { return "`" + name + "`" @@ -432,7 +443,7 @@ function buildTableDdl( ): string { const parts: string[] = [] for (const col of def.columns) { - let line = `${quote(col.name)} ${sqlType(col, dialect)}` + let line = `${quote(col.name)} ${ddlColumnType(col, dialect)}` if (col.pk) { line += " PRIMARY KEY" } else if (!col.nullable) { @@ -446,6 +457,28 @@ function buildTableDdl( return `CREATE TABLE IF NOT EXISTS ${quote(tableName)} (${parts.join(", ")})` } +export function buildAddColumnDdl( + table: DdlTableName, + columnName: string, + dialect: "sqlite" | "mysql", + env?: any, +): string { + const column = TABLES[table].columns.find( + (item) => item.name === columnName, + ) + if (!column) { + throw new Error(`Unknown column ${table}.${columnName}`) + } + let definition = `${quote(column.name)} ${ddlColumnType(column, dialect)}` + if (!column.nullable) definition += " NOT NULL" + if (column.unique) definition += " UNIQUE" + return `ALTER TABLE ${quote(tableSqlName(table, env))} ADD COLUMN ${definition}` +} + +export function buildTableInfoDdl(table: DdlTableName, env?: any): string { + return `PRAGMA table_info(${quote(tableSqlName(table, env))})` +} + /** * 生成 schema_info 表(标记 SQL 格式是否已初始化)。 */ diff --git a/src/backend/internal/model/store/store.test.ts b/src/backend/internal/model/store/store.test.ts index 0c05c9a4..15b105be 100644 --- a/src/backend/internal/model/store/store.test.ts +++ b/src/backend/internal/model/store/store.test.ts @@ -21,7 +21,12 @@ import { deserializeColumn, rowToEntity, entityToRow, + buildAddColumnDdl, + buildTableInfoDdl, } from "./schema" +import { d1Driver } from "./driver/d1" +import { __ensureMysqlSchemaForTest } from "./driver/mysql" +import { OpenListDB } from "../../../durable-objects/OpenListDB" /** 基于局部 Map 的内存 KV 驱动(隔离测试)。 */ function createMockKvDriver(): Driver { @@ -158,7 +163,23 @@ const SAMPLE_DB = { ], shares: [], metas: [{ id: 1, path: "/a", read_users: [1, 2], read_users_sub: false }], - plugins: [], + plugins: [ + { + id: "com.example.docs", + name: "Docs", + version: "1.0.0", + enabled: true, + manifest: { + apiVersion: "v1", + id: "com.example.docs", + version: "1.0.0", + displayName: "Docs", + description: "", + capabilities: ["files_read"], + settingsSchema: { type: "object" }, + }, + }, + ], } test("schema: columnar tables match Go backend structure", () => { @@ -368,7 +389,7 @@ test("secret persistence: degrades gracefully when no backend is available", asy test("getKvBinding: 同一 env 只解析一次(含 mode=none),重复调用返回同一缓存对象", async () => { // 回归:此前只有「成功」的绑定会被缓存,`mode=none` 直接 return,于是每次 - // 调用都会重新探测 env.KV / globalThis.KV、重试 Blob SDK 初始化,并重新打印 + // 调用都要重新探测 env.KV / globalThis.KV、重试 Blob SDK 初始化,并重新打印 // 一次告警。登录失败计数、注销黑名单、审计日志读写都在这条路径上 // (serverless 日志被同一行刷屏的来源之一)。 const { getKvBinding } = await import("./json") @@ -394,3 +415,241 @@ test("getKvBinding: 同一 env 只解析一次(含 mode=none),重复调用 "「未探测到 KV 绑定」的告警每个进程最多打印一次", ) }) + +test("schema: plugin manifest column and migration SQL are available", () => { + const pluginDdl = D1_SCHEMA.find((sql) => sql.includes("x_plugins")) + assert.ok(pluginDdl?.includes("`manifest` TEXT")) + assert.equal( + buildAddColumnDdl("plugins", "manifest", "sqlite"), + "ALTER TABLE `x_plugins` ADD COLUMN `manifest` TEXT", + ) + const mysqlDdl = MYSQL_SCHEMA.join("\n") + assert.doesNotMatch(mysqlDdl, /TEXT\s+(?:PRIMARY KEY|UNIQUE)/) + assert.match( + MYSQL_SCHEMA.find((sql) => sql.includes("x_plugins")) || "", + /`id` VARCHAR\(255\) PRIMARY KEY/, + ) + assert.equal( + buildAddColumnDdl("plugins", "manifest", "mysql"), + "ALTER TABLE `x_plugins` ADD COLUMN `manifest` TEXT", + ) + assert.equal( + buildTableInfoDdl("plugins"), + "PRAGMA table_info(`x_plugins`)", + ) +}) + +function createD1SchemaBinding( + columns: string[], + options: { failFirstAlter?: boolean; duplicateFirstAlter?: boolean } = {}, +) { + const sql: string[] = [] + const stats = { batchCalls: 0 } + let failFirstAlter = Boolean(options.failFirstAlter) + let duplicateFirstAlter = Boolean(options.duplicateFirstAlter) + const db = { + prepare(statement: string) { + sql.push(statement) + let params: any[] = [] + const stmt = { + bind(...args: any[]) { + params = args + return stmt + }, + async first() { + return null + }, + async all() { + if (/^PRAGMA table_info/i.test(statement)) { + return { results: columns.map((name) => ({ name })) } + } + return { results: [] } + }, + async run() { + void params + if (statement.includes("ADD COLUMN `manifest`")) { + if (failFirstAlter) { + failFirstAlter = false + throw new Error("alter failed") + } + if (duplicateFirstAlter) { + duplicateFirstAlter = false + if (!columns.includes("manifest")) columns.push("manifest") + throw new Error("duplicate column name: manifest") + } + if (!columns.includes("manifest")) columns.push("manifest") + } + return { success: true } + }, + } + return stmt + }, + async batch(statements: any[]) { + stats.batchCalls++ + for (const statement of statements) await statement.run() + return [] + }, + } + return { db, sql, stats, columns } +} + +test("D1 schema migration adds the plugin manifest column exactly once", async () => { + const oldSchema = createD1SchemaBinding(["id", "name"]) + await Promise.all([ + d1Driver.init({ DB: oldSchema.db }), + d1Driver.init({ DB: oldSchema.db }), + d1Driver.init({ DB: oldSchema.db }), + ]) + assert.equal( + oldSchema.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")) + .length, + 1, + ) + + const newSchema = createD1SchemaBinding(["id", "name", "manifest"]) + await d1Driver.init({ DB: newSchema.db }) + assert.equal( + newSchema.sql.some((sql) => sql.includes("ADD COLUMN `manifest`")), + false, + ) +}) + +test("D1 schema migration retries after a failed concurrent attempt", async () => { + const schema = createD1SchemaBinding(["id", "name"], { + failFirstAlter: true, + }) + await assert.rejects( + Promise.all([ + d1Driver.init({ DB: schema.db }), + d1Driver.init({ DB: schema.db }), + ]), + ) + assert.equal( + schema.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")) + .length, + 1, + ) + await d1Driver.init({ DB: schema.db }) + assert.equal( + schema.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")) + .length, + 2, + ) +}) + +test("D1 schema migration accepts a cross-isolate duplicate after recheck", async () => { + const schema = createD1SchemaBinding(["id"], { + duplicateFirstAlter: true, + }) + await d1Driver.init({ DB: schema.db }) + assert.deepEqual(schema.columns, ["id", "manifest"]) +}) + +test("D1 batch uses one native transaction", async () => { + const schema = createD1SchemaBinding(["id", "manifest"]) + const statements = Array.from({ length: 250 }, (_, index) => ({ + sql: `UPDATE plugins SET version = ? WHERE id = ?`, + params: [`${index}`, `${index}`], + })) + await d1Driver.batch!(statements, { DB: schema.db }) + assert.equal(schema.stats.batchCalls, 1) +}) + +function createMysqlSchemaPool( + columns: string[], + options: { failFirstAlter?: boolean; duplicateFirstAlter?: boolean } = {}, +) { + const sql: string[] = [] + let failFirstAlter = Boolean(options.failFirstAlter) + let duplicateFirstAlter = Boolean(options.duplicateFirstAlter) + return { + sql, + columns, + async query(statement: string) { + sql.push(statement) + if (statement.includes("information_schema.COLUMNS")) { + return [ + columns.includes("manifest") + ? [{ COLUMN_NAME: "manifest" }] + : [], + ] + } + if (statement.includes("ADD COLUMN `manifest`")) { + if (duplicateFirstAlter) { + duplicateFirstAlter = false + columns.push("manifest") + const error: any = new Error("Duplicate column name 'manifest'") + error.code = "ER_DUP_FIELDNAME" + throw error + } + if (failFirstAlter) { + failFirstAlter = false + throw new Error("alter failed") + } + columns.push("manifest") + } + return [[], []] + }, + } +} + +test("MySQL schema migration is single-flight per pool and isolated across pools", async () => { + const first = createMysqlSchemaPool(["id"]) + await Promise.all([ + __ensureMysqlSchemaForTest(first, {}), + __ensureMysqlSchemaForTest(first, {}), + ]) + assert.equal( + first.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")).length, + 1, + ) + + const second = createMysqlSchemaPool(["id"]) + await __ensureMysqlSchemaForTest(second, {}) + assert.equal( + second.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")) + .length, + 1, + ) + + const retry = createMysqlSchemaPool(["id"], { failFirstAlter: true }) + await assert.rejects(__ensureMysqlSchemaForTest(retry, {})) + await __ensureMysqlSchemaForTest(retry, {}) + assert.equal( + retry.sql.filter((sql) => sql.includes("ADD COLUMN `manifest`")).length, + 2, + ) +}) + +test("MySQL schema migration accepts a cross-instance duplicate after recheck", async () => { + const schema = createMysqlSchemaPool(["id"], { + duplicateFirstAlter: true, + }) + await __ensureMysqlSchemaForTest(schema, {}) + assert.deepEqual(schema.columns, ["id", "manifest"]) +}) + +test("Durable Object schema migration adds the plugin manifest column", async () => { + const sql: string[] = [] + const state = { + storage: { + sql: { + exec(statement: string) { + sql.push(statement) + return { + toArray: () => + /^PRAGMA table_info/i.test(statement) ? [{ name: "id" }] : [], + } + }, + }, + transactionSync(callback: () => void) { + callback() + }, + }, + } + const database = new OpenListDB(state) + await database.init() + assert.ok( + sql.includes("ALTER TABLE `x_plugins` ADD COLUMN `manifest` TEXT"), + ) +}) diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index a23583a5..61899261 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -5,9 +5,17 @@ import { defaultDb, getKvStatus, getStoreStatus, + reloadDb, } from "../internal/model/db" import { getDriver } from "../internal/op/storage" +import { readFormat } from "../internal/model/store/backend" import { search } from "../internal/op/search" +import { + applyPluginManifest, + parsePluginManifest, + PluginManifestError, + PLUGIN_MANIFEST_MAX_BYTES, +} from "../internal/model/plugin" import { checkAdminAuth } from "../pkg/utils" import { safeErrorMessage } from "../pkg/errs" import { validateHide } from "../pkg/meta" @@ -4806,274 +4814,597 @@ adminRouter.get("/scan/progress", async (c) => { }) }) +function pluginManifestInput(payload: any): unknown | undefined { + if (payload && typeof payload === "object") { + if (Object.prototype.hasOwnProperty.call(payload, "manifest")) { + return payload.manifest + } + if (payload.apiVersion !== undefined) return payload + } + return undefined +} + +function pluginLegacyPayload(payload: any): Record { + if (!payload || typeof payload !== "object") return {} + const legacy = { ...payload } + delete legacy.manifest + delete legacy.apiVersion + delete legacy.displayName + delete legacy.capabilities + delete legacy.settingsSchema + delete legacy.entry + return legacy +} + +function normalizedPluginId(id: unknown): string { + return String(id || "").toLowerCase() +} + +function validLegacyPluginId(id: unknown): id is string { + return ( + typeof id === "string" && + id.trim().length > 0 && + Array.from(id).length <= 255 && + !/[\u0000-\u001f\u007f]/.test(id) + ) +} + +function rejectUnsupportedPluginWrite(c: any) { + if (readFormat(c.env) !== "key") return null + return c.json( + { + code: 409, + message: + "Plugin writes require atomic persistence; DB_FORMAT=key is not supported", + data: { + code: "ATOMIC_PLUGIN_PERSISTENCE_UNSUPPORTED", + suggestion: "Use DB_FORMAT=map or DB_FORMAT=sql", + }, + }, + 409, + ) +} + +function pluginSnapshot(db: any, plugins: any[]): any { + return { ...db, plugins } +} + +let pluginWriteTail = Promise.resolve() + +async function withPluginWriteLock(task: () => Promise): Promise { + const prior = pluginWriteTail + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const tail = prior.catch(() => {}).then(() => gate) + pluginWriteTail = tail + await prior.catch(() => {}) + try { + return await task() + } finally { + release() + if (pluginWriteTail === tail) pluginWriteTail = Promise.resolve() + } +} + +function serializedPluginRoute( + handler: (c: any) => Promise, +): (c: any) => Promise { + return (c: any) => withPluginWriteLock(() => handler(c)) +} + +async function readPluginJson( + c: any, + options: { allowArray?: boolean } = {}, +): Promise { + let body: any + try { + body = await c.req.json() + } catch { + throw new PluginManifestError("request body must be valid JSON") + } + if ( + !body || + typeof body !== "object" || + (Array.isArray(body) && !options.allowArray) + ) { + throw new PluginManifestError("request body must be a JSON object") + } + return body +} + +async function readPluginManifestResponse(response: Response): Promise { + const declared = Number(response.headers.get("content-length") || 0) + if (declared > PLUGIN_MANIFEST_MAX_BYTES) { + await response.body?.cancel().catch(() => {}) + throw new PluginManifestError("manifest URL response is too large") + } + if (!response.body) return "" + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > PLUGIN_MANIFEST_MAX_BYTES) { + throw new PluginManifestError("manifest URL response is too large") + } + chunks.push(value) + } + } catch (err: any) { + await reader.cancel().catch(() => {}) + throw err + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(bytes) +} + +async function fetchPluginManifest( + env: any, + rawUrl: string, +): Promise { + let target: URL + let trustedOrigin: URL + try { + target = new URL(rawUrl) + const configured = String( + env?.PLUGIN_MANIFEST_ORIGIN || + (typeof process !== "undefined" + ? process.env?.PLUGIN_MANIFEST_ORIGIN + : "") || + "", + ).trim() + if (!configured) { + throw new PluginManifestError( + "PLUGIN_MANIFEST_ORIGIN is required for manifest_url installs", + ) + } + trustedOrigin = new URL(configured) + } catch (err: any) { + if (err instanceof PluginManifestError) throw err + throw new PluginManifestError("manifest_url or trusted origin is invalid") + } + if (!["http:", "https:"].includes(target.protocol)) { + throw new PluginManifestError("manifest_url must use HTTP or HTTPS") + } + if (target.origin !== trustedOrigin.origin) { + throw new PluginManifestError( + "manifest_url must match PLUGIN_MANIFEST_ORIGIN", + ) + } + if (target.username || target.password) { + throw new PluginManifestError("manifest_url must not contain credentials") + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5_000) + try { + const response = await fetch(target.href, { + redirect: "error", + signal: controller.signal, + }) + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new PluginManifestError( + `Failed to fetch plugin manifest from URL: HTTP ${response.status}`, + ) + } + const text = await readPluginManifestResponse(response) + try { + return JSON.parse(text) + } catch { + throw new PluginManifestError("manifest_url did not return valid JSON") + } + } catch (err: any) { + if (err instanceof PluginManifestError) throw err + throw new PluginManifestError( + `Network error fetching plugin manifest: ${safeErrorMessage(err, "request failed")}`, + ) + } finally { + clearTimeout(timeout) + } +} + +function pluginErrorResponse(c: any, err: any, fallback: string) { + if (err instanceof PluginManifestError) { + return c.json( + { code: 400, message: err.message, data: { code: err.code } }, + 400, + ) + } + return c.json( + { code: 503, message: err?.message || fallback, data: null }, + 503, + ) +} + // --- Plugin Management API --- adminRouter.get("/plugin/list", async (c) => { - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] + const db = await reloadDb(c.env) + const plugins = Array.isArray(db.plugins) ? db.plugins : [] return c.json({ code: 200, message: "success", data: { - content: db.plugins, - total: db.plugins.length, + content: plugins, + total: plugins.length, }, }) }) adminRouter.get("/plugin/get", async (c) => { const id = c.req.query("id") - if (!id) { - return c.json({ code: 400, message: "id is required", data: null }) + if (!validLegacyPluginId(id)) { + return c.json({ code: 400, message: "id is invalid", data: null }, 400) } - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] - const plugin = db.plugins.find((p: any) => p.id === id) + const db = await reloadDb(c.env) + const plugins = Array.isArray(db.plugins) ? db.plugins : [] + const plugin = plugins.find( + (candidate: any) => + normalizedPluginId(candidate.id) === normalizedPluginId(id), + ) if (!plugin) { return c.json({ code: 404, message: "Plugin not found", data: null }) } return c.json({ code: 200, message: "success", data: plugin }) }) -adminRouter.post("/plugin/install", async (c) => { - try { - const body = await c.req.json() - let pluginData = body - - // Support install by manifest URL - if (body.manifest_url && typeof body.manifest_url === "string") { - try { - const resp = await fetch(body.manifest_url) - if (!resp.ok) { - return c.json({ - code: 400, - message: `Failed to fetch plugin manifest from URL: HTTP ${resp.status}`, - data: null, - }) - } - const fetchedManifest = await resp.json() - pluginData = { ...fetchedManifest, ...body } - } catch (err: any) { - return c.json({ - code: 400, - message: `Network error fetching plugin manifest: ${safeErrorMessage(err, "unexpected network error")}`, - data: null, - }) +adminRouter.post( + "/plugin/install", + serializedPluginRoute(async (c) => { + try { + const unsupported = rejectUnsupportedPluginWrite(c) + if (unsupported) return unsupported + const body = await readPluginJson(c) + let pluginData = body + + if (body.manifest_url && typeof body.manifest_url === "string") { + const fetchedManifest = await fetchPluginManifest( + c.env, + body.manifest_url, + ) + pluginData = { ...body, manifest: fetchedManifest } } - } - if (!pluginData.id || !pluginData.name) { - return c.json({ - code: 400, - message: "Plugin id and name are required", - data: null, - }) - } + const manifestInput = pluginManifestInput(pluginData) + const manifest = + manifestInput === undefined ? null : parsePluginManifest(manifestInput) + const legacy = pluginLegacyPayload(pluginData) + const id = manifest?.id || legacy.id + const name = manifest?.displayName || legacy.name + if (!manifest && !validLegacyPluginId(id)) { + return c.json( + { code: 400, message: "Plugin id is invalid", data: null }, + 400, + ) + } + if (!id || !name) { + return c.json( + { code: 400, message: "Plugin id and name are required", data: null }, + 400, + ) + } - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] + const db = await reloadDb(c.env) + const currentPlugins = Array.isArray(db.plugins) ? db.plugins : [] + const existingIndex = currentPlugins.findIndex( + (plugin: any) => + normalizedPluginId(plugin.id) === normalizedPluginId(id), + ) + const previous = existingIndex >= 0 ? currentPlugins[existingIndex] : {} + const now = new Date().toISOString() + let newPlugin: any = { + ...previous, + id, + name, + version: legacy.version ?? previous.version ?? "1.0.0", + description: legacy.description ?? previous.description ?? "", + author: legacy.author ?? previous.author ?? "Unknown", + homepage: legacy.homepage ?? previous.homepage ?? "", + repository: legacy.repository ?? previous.repository ?? "", + icon: legacy.icon ?? previous.icon ?? "", + type: legacy.type ?? previous.type ?? "ui", + enabled: Boolean(legacy.enabled ?? previous.enabled ?? true), + high_privilege: Boolean( + legacy.high_privilege ?? previous.high_privilege ?? false, + ), + permissions: Array.isArray(legacy.permissions) + ? legacy.permissions + : Array.isArray(previous.permissions) + ? previous.permissions + : [], + entry_url: legacy.entry_url ?? previous.entry_url ?? "", + script_content: legacy.script_content ?? previous.script_content ?? "", + style_content: legacy.style_content ?? previous.style_content ?? "", + config_schema: legacy.config_schema ?? previous.config_schema ?? [], + config_values: + legacy.config_values ?? + legacy.default_config ?? + previous.config_values ?? + {}, + target_hooks: legacy.target_hooks ?? + previous.target_hooks ?? ["global"], + is_builtin: Boolean(legacy.is_builtin ?? previous.is_builtin ?? false), + tags: legacy.tags ?? previous.tags ?? [], + created_at: previous.created_at ?? now, + updated_at: now, + } + if (manifest) { + newPlugin = applyPluginManifest(newPlugin, manifest) + } else { + delete newPlugin.manifest + } - const existingIndex = db.plugins.findIndex( - (p: any) => p.id === pluginData.id, - ) - const now = new Date().toISOString() - const newPlugin = { - id: pluginData.id, - name: pluginData.name, - version: pluginData.version || "1.0.0", - description: pluginData.description || "", - author: pluginData.author || "Unknown", - homepage: pluginData.homepage || "", - repository: pluginData.repository || "", - icon: pluginData.icon || "", - type: pluginData.type || "ui", - enabled: - pluginData.enabled !== undefined ? Boolean(pluginData.enabled) : true, - high_privilege: Boolean(pluginData.high_privilege), - permissions: Array.isArray(pluginData.permissions) - ? pluginData.permissions - : [], - entry_url: pluginData.entry_url || "", - script_content: pluginData.script_content || "", - style_content: pluginData.style_content || "", - config_schema: pluginData.config_schema || [], - config_values: - pluginData.config_values || pluginData.default_config || {}, - target_hooks: pluginData.target_hooks || ["global"], - is_builtin: Boolean(pluginData.is_builtin), - tags: pluginData.tags || [], - created_at: - existingIndex >= 0 ? db.plugins[existingIndex].created_at : now, - updated_at: now, - } + const nextPlugins = currentPlugins.slice() + if (existingIndex >= 0) nextPlugins[existingIndex] = newPlugin + else nextPlugins.push(newPlugin) - if (existingIndex >= 0) { - db.plugins[existingIndex] = newPlugin - } else { - db.plugins.push(newPlugin) + if ( + !(await saveDb(pluginSnapshot(db, nextPlugins), c.env, { + publishOnSuccess: true, + })) + ) { + return c.json( + { code: 503, message: "Failed to persist plugin", data: null }, + 503, + ) + } + return c.json({ + code: 200, + message: "Plugin installed successfully", + data: newPlugin, + }) + } catch (err: any) { + return pluginErrorResponse(c, err, "Failed to install plugin") } + }), +) - await saveDb(db, c.env) - return c.json({ - code: 200, - message: "Plugin installed successfully", - data: newPlugin, - }) - } catch (err: any) { - return c.json({ - code: 500, - message: err.message || "Failed to install plugin", - data: null, - }) - } -}) +adminRouter.post( + "/plugin/update", + serializedPluginRoute(async (c) => { + try { + const unsupported = rejectUnsupportedPluginWrite(c) + if (unsupported) return unsupported + const body = await readPluginJson(c) + if (!validLegacyPluginId(body.id)) { + return c.json( + { code: 400, message: "Plugin id is required", data: null }, + 400, + ) + } -adminRouter.post("/plugin/update", async (c) => { - try { - const body = await c.req.json() - if (!body.id) { - return c.json({ code: 400, message: "Plugin id is required", data: null }) - } + const db = await reloadDb(c.env) + const currentPlugins = Array.isArray(db.plugins) ? db.plugins : [] + const index = currentPlugins.findIndex( + (plugin: any) => + normalizedPluginId(plugin.id) === normalizedPluginId(body.id), + ) + if (index === -1) { + return c.json( + { code: 404, message: "Plugin not found", data: null }, + 404, + ) + } - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] + const current = currentPlugins[index] + let updated: any = { + ...current, + ...pluginLegacyPayload(body), + id: current.id, + updated_at: new Date().toISOString(), + } + const manifestInput = pluginManifestInput(body) + if (manifestInput !== undefined) { + updated = applyPluginManifest(updated, manifestInput) + } - const index = db.plugins.findIndex((p: any) => p.id === body.id) - if (index === -1) { - return c.json({ code: 404, message: "Plugin not found", data: null }) - } + const nextPlugins = currentPlugins.slice() + nextPlugins[index] = updated + if ( + !(await saveDb(pluginSnapshot(db, nextPlugins), c.env, { + publishOnSuccess: true, + })) + ) { + return c.json( + { code: 503, message: "Failed to persist plugin", data: null }, + 503, + ) + } - const current = db.plugins[index] - const updated = { - ...current, - ...body, - id: current.id, // prevent ID mutation - updated_at: new Date().toISOString(), + return c.json({ + code: 200, + message: "Plugin updated successfully", + data: updated, + }) + } catch (err: any) { + return pluginErrorResponse(c, err, "Failed to update plugin") } + }), +) - db.plugins[index] = updated - await saveDb(db, c.env) - - return c.json({ - code: 200, - message: "Plugin updated successfully", - data: updated, - }) - } catch (err: any) { - return c.json({ - code: 500, - message: err.message || "Failed to update plugin", - data: null, - }) - } -}) +adminRouter.post( + "/plugin/toggle", + serializedPluginRoute(async (c) => { + try { + const unsupported = rejectUnsupportedPluginWrite(c) + if (unsupported) return unsupported + const body = await readPluginJson(c) + if (!validLegacyPluginId(body.id)) { + return c.json( + { code: 400, message: "Plugin id is required", data: null }, + 400, + ) + } -adminRouter.post("/plugin/toggle", async (c) => { - try { - const body = await c.req.json() - if (!body.id) { - return c.json({ code: 400, message: "Plugin id is required", data: null }) - } + const db = await reloadDb(c.env) + const currentPlugins = Array.isArray(db.plugins) ? db.plugins : [] + const index = currentPlugins.findIndex( + (plugin: any) => + normalizedPluginId(plugin.id) === normalizedPluginId(body.id), + ) + if (index === -1) { + return c.json( + { code: 404, message: "Plugin not found", data: null }, + 404, + ) + } - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] + const current = currentPlugins[index] + const targetEnabled = + body.enabled !== undefined ? Boolean(body.enabled) : !current.enabled + const nextPlugins = currentPlugins.slice() + nextPlugins[index] = { + ...current, + enabled: targetEnabled, + updated_at: new Date().toISOString(), + } + if ( + !(await saveDb(pluginSnapshot(db, nextPlugins), c.env, { + publishOnSuccess: true, + })) + ) { + return c.json( + { code: 503, message: "Failed to persist plugin", data: null }, + 503, + ) + } - const index = db.plugins.findIndex((p: any) => p.id === body.id) - if (index === -1) { - return c.json({ code: 404, message: "Plugin not found", data: null }) + return c.json({ + code: 200, + message: targetEnabled ? "Plugin enabled" : "Plugin disabled", + data: { id: body.id, enabled: targetEnabled }, + }) + } catch (err: any) { + return pluginErrorResponse(c, err, "Failed to toggle plugin") } + }), +) - const targetEnabled = - body.enabled !== undefined - ? Boolean(body.enabled) - : !db.plugins[index].enabled - - db.plugins[index].enabled = targetEnabled - db.plugins[index].updated_at = new Date().toISOString() - await saveDb(db, c.env) +adminRouter.post( + "/plugin/delete", + serializedPluginRoute(async (c) => { + try { + const unsupported = rejectUnsupportedPluginWrite(c) + if (unsupported) return unsupported + const queryId = c.req.query("id") + let id = queryId + if (!id) { + const body = await readPluginJson(c) + id = body?.id + } - return c.json({ - code: 200, - message: targetEnabled ? "Plugin enabled" : "Plugin disabled", - data: { id: body.id, enabled: targetEnabled }, - }) - } catch (err: any) { - return c.json({ - code: 500, - message: err.message || "Failed to toggle plugin", - data: null, - }) - } -}) + if (!validLegacyPluginId(id)) { + return c.json( + { code: 400, message: "Plugin id is invalid", data: null }, + 400, + ) + } -adminRouter.post("/plugin/delete", async (c) => { - try { - const queryId = c.req.query("id") - let id = queryId - if (!id) { - try { - const body = await c.req.json() - id = body.id - } catch {} - } + const db = await reloadDb(c.env) + const currentPlugins = Array.isArray(db.plugins) ? db.plugins : [] + const nextPlugins = currentPlugins.filter( + (plugin: any) => + normalizedPluginId(plugin.id) !== normalizedPluginId(id), + ) + if (nextPlugins.length === currentPlugins.length) { + return c.json( + { code: 404, message: "Plugin not found", data: null }, + 404, + ) + } - if (!id) { - return c.json({ code: 400, message: "Plugin id is required", data: null }) + if ( + !(await saveDb(pluginSnapshot(db, nextPlugins), c.env, { + publishOnSuccess: true, + })) + ) { + return c.json( + { code: 503, message: "Failed to persist plugin", data: null }, + 503, + ) + } + return c.json({ + code: 200, + message: "Plugin deleted successfully", + data: null, + }) + } catch (err: any) { + return pluginErrorResponse(c, err, "Failed to delete plugin") } + }), +) - const db = await getDb(c.env) - if (!db.plugins) db.plugins = [] - - const initialLen = db.plugins.length - db.plugins = db.plugins.filter((p: any) => p.id !== id) +adminRouter.post( + "/plugin/batch_save", + serializedPluginRoute(async (c) => { + try { + const unsupported = rejectUnsupportedPluginWrite(c) + if (unsupported) return unsupported + const body = await readPluginJson(c, { allowArray: true }) + const plugins = Array.isArray(body) ? body : body?.plugins + if (!Array.isArray(plugins)) { + return c.json( + { code: 400, message: "plugins array is required", data: null }, + 400, + ) + } - if (db.plugins.length === initialLen) { - return c.json({ code: 404, message: "Plugin not found", data: null }) - } + const seenIds = new Set() + const validated = plugins.map((plugin: any) => { + const manifestInput = pluginManifestInput(plugin) + const legacy = pluginLegacyPayload(plugin) + let validatedPlugin = legacy + if (manifestInput !== undefined) { + const manifest = parsePluginManifest(manifestInput) + validatedPlugin = applyPluginManifest( + { ...legacy, id: manifest.id }, + manifest, + ) + } + if (!validLegacyPluginId(validatedPlugin.id)) { + throw new PluginManifestError("every plugin requires a valid id") + } + const normalizedId = validatedPlugin.id.toLowerCase() + if (seenIds.has(normalizedId)) { + throw new PluginManifestError( + `duplicate plugin id: ${validatedPlugin.id}`, + ) + } + seenIds.add(normalizedId) + return validatedPlugin + }) - await saveDb(db, c.env) - return c.json({ - code: 200, - message: "Plugin deleted successfully", - data: null, - }) - } catch (err: any) { - return c.json({ - code: 500, - message: err.message || "Failed to delete plugin", - data: null, - }) - } -}) + const db = await reloadDb(c.env) + if ( + !(await saveDb(pluginSnapshot(db, validated), c.env, { + publishOnSuccess: true, + })) + ) { + return c.json( + { code: 503, message: "Failed to persist plugins", data: null }, + 503, + ) + } -adminRouter.post("/plugin/batch_save", async (c) => { - try { - const body = await c.req.json() - const plugins = Array.isArray(body) ? body : body.plugins - if (!Array.isArray(plugins)) { return c.json({ - code: 400, - message: "plugins array is required", - data: null, + code: 200, + message: "Plugins saved successfully", + data: { count: validated.length }, }) + } catch (err: any) { + return pluginErrorResponse(c, err, "Failed to batch save plugins") } - - const db = await getDb(c.env) - db.plugins = plugins - await saveDb(db, c.env) - - return c.json({ - code: 200, - message: "Plugins saved successfully", - data: { count: plugins.length }, - }) - } catch (err: any) { - return c.json({ - code: 500, - message: err.message || "Failed to batch save plugins", - data: null, - }) - } -}) + }), +) // ---- Message(与 Go internal/message/http.go 对齐)---- // 进程内消息队列:send 入队、get 出队。Serverless 多实例下队列不跨实例共享, diff --git a/src/backend/server/plugin_manifest.test.ts b/src/backend/server/plugin_manifest.test.ts new file mode 100644 index 00000000..4851b3e8 --- /dev/null +++ b/src/backend/server/plugin_manifest.test.ts @@ -0,0 +1,611 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { + __resetDbCacheForTest, + __setStoreBackendLoaderForTest, +} from "../internal/model/db" +import { adminRouter } from "./admin" +import { publicRouter } from "./public" + +const TOKEN = "plugin-test-token" +const manifest = { + apiVersion: "v1", + id: "com.example.docs", + version: "1.0.0", + displayName: "Docs", + description: "Documentation tools", + capabilities: ["files_read"], + settingsSchema: { type: "object" }, + entry: { module: "index.js" }, +} + +function setup( + options: { + format?: string + onSave?: (next: any, current: any) => Promise + } = {}, +) { + __resetDbCacheForTest() + let stored: any = { + settings: [{ key: "token", value: TOKEN }], + users: [], + storages: [], + shares: [], + metas: [], + plugins: [], + } + const backend = { + name: "plugin-test", + isConfigured: async () => true, + load: async () => JSON.parse(JSON.stringify(stored)), + save: async (next: any) => { + if (options.onSave) return options.onSave(next, stored) + stored = JSON.parse(JSON.stringify(next)) + return true + }, + } + __setStoreBackendLoaderForTest(async () => backend) + const env: any = { + DB_DRIVER: "plugin-test", + DB_FORMAT: options.format || "map", + PLUGIN_MANIFEST_ORIGIN: "http://localhost", + JWT_SECRET: "plugin-test-secret-0123456789abcdef", + } + return { + env, + getStored: () => stored, + } +} + +function adminApp() { + const app = new Hono() + app.route("/api/admin", adminRouter) + return app +} + +function publicApp() { + const app = new Hono() + app.route("/api/public", publicRouter) + return app +} + +async function install(app: Hono, env: any, body: any) { + return app.request( + "/api/admin/plugin/install", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + env, + ) +} + +async function listPlugins(app: Hono, env: any) { + return app.request( + "/api/admin/plugin/list", + { headers: { Authorization: TOKEN } }, + env, + ) +} + +test( + "plugin install accepts a v1 manifest and preserves compatibility fields", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + try { + const res = await install(adminApp(), env, { + manifest, + enabled: false, + config_values: { keep: "value" }, + }) + assert.equal(res.status, 200) + const body: any = await res.json() + assert.equal(body.data.id, manifest.id) + assert.equal(body.data.name, manifest.displayName) + assert.equal(body.data.version, manifest.version) + assert.equal(body.data.enabled, false) + assert.deepEqual(body.data.config_values, { keep: "value" }) + assert.deepEqual(body.data.manifest, manifest) + + const raw = await install(adminApp(), env, { + ...manifest, + id: "com.example.raw", + displayName: "Raw", + }) + assert.equal(raw.status, 200) + assert.equal(getStored().plugins.length, 2) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "concurrent plugin installs serialize read-modify-write", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + const app = adminApp() + const secondEnv = { ...env } + try { + const results = await Promise.all([ + install(app, env, { manifest }), + install(app, secondEnv, { + manifest: { ...manifest, id: "com.example.second" }, + }), + ]) + assert.deepEqual( + results.map((response) => response.status), + [200, 200], + ) + assert.equal(getStored().plugins.length, 2) + const listResponse = await listPlugins(app, env) + const listBody: any = await listResponse.json() + assert.equal(listBody.data.content.length, 2) + const publicResponse = await publicApp().request( + "/api/public/plugins", + {}, + env, + ) + const publicBody: any = await publicResponse.json() + assert.equal(publicBody.data.length, 2) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "plugin install rejects invalid manifests without changing storage", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + try { + const malformed = await adminApp().request( + "/api/admin/plugin/install", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: "{", + }, + env, + ) + assert.equal(malformed.status, 400) + + const nullBody = await adminApp().request( + "/api/admin/plugin/install", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: "null", + }, + env, + ) + assert.equal(nullBody.status, 400) + + const res = await install(adminApp(), env, { + manifest: { ...manifest, capabilities: ["files.read"] }, + }) + assert.equal(res.status, 400) + const body: any = await res.json() + assert.equal(body.code, 400) + assert.match(body.message, /capabilities/) + assert.deepEqual(getStored().plugins, []) + + const legacy = await install(adminApp(), env, { + id: "legacy.plugin", + name: "Legacy", + }) + assert.equal(legacy.status, 200) + assert.equal(getStored().plugins[0].manifest, undefined) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "plugin writes reject non-atomic key format", + { concurrency: false }, + async () => { + const { env, getStored } = setup({ format: "key" }) + try { + const res = await install(adminApp(), env, { manifest }) + assert.equal(res.status, 409) + const body: any = await res.json() + assert.equal(body.data.code, "ATOMIC_PLUGIN_PERSISTENCE_UNSUPPORTED") + assert.deepEqual(getStored().plugins, []) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "failed plugin persistence never publishes the candidate snapshot", + { concurrency: false }, + async () => { + let saveAttempts = 0 + const { env, getStored } = setup({ + onSave: async () => { + saveAttempts++ + if (saveAttempts === 1) throw new Error("backend unavailable") + return false + }, + }) + const app = adminApp() + try { + const res = await install(app, env, { manifest }) + assert.equal(res.status, 503) + assert.deepEqual(getStored().plugins, []) + const listResponse = await listPlugins(app, env) + const listBody: any = await listResponse.json() + assert.deepEqual(listBody.data.content, []) + const publicResponse = await publicApp().request( + "/api/public/plugins", + {}, + env, + ) + const publicBody: any = await publicResponse.json() + assert.deepEqual(publicBody.data, []) + + const falseResult = await install(app, env, { + manifest: { ...manifest, id: "com.example.false" }, + }) + assert.equal(falseResult.status, 503) + assert.deepEqual(getStored().plugins, []) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "manifest reinstall preserves legacy-only plugin fields", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + try { + const legacy = { + id: manifest.id, + name: "Legacy", + version: "0.9.0", + description: "Legacy description", + author: "Author", + homepage: "https://example.com", + repository: "https://example.com/repo", + icon: "icon.png", + type: "ui", + enabled: false, + high_privilege: true, + permissions: ["files_read"], + entry_url: "index.js", + script_content: "script", + style_content: "style", + config_schema: [{ key: "token" }], + config_values: { token: "keep" }, + target_hooks: ["global"], + is_builtin: true, + tags: ["legacy"], + } + assert.equal((await install(adminApp(), env, legacy)).status, 200) + assert.equal((await install(adminApp(), env, { manifest })).status, 200) + const stored = getStored().plugins[0] + assert.equal(stored.name, manifest.displayName) + assert.equal(stored.author, "Author") + assert.equal(stored.entry_url, "index.js") + assert.equal(stored.script_content, "script") + assert.equal(stored.style_content, "style") + assert.deepEqual(stored.config_schema, [{ key: "token" }]) + assert.deepEqual(stored.config_values, { token: "keep" }) + assert.deepEqual(stored.target_hooks, ["global"]) + assert.equal(stored.is_builtin, true) + assert.deepEqual(stored.tags, ["legacy"]) + assert.equal(stored.enabled, false) + assert.equal(stored.high_privilege, true) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "legacy plugin IDs are length-bounded and case-insensitive", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + const app = adminApp() + try { + assert.equal( + ( + await install(app, env, { + id: "Legacy.Plugin", + name: "Legacy", + }) + ).status, + 200, + ) + assert.equal( + ( + await install(app, env, { + id: "legacy.plugin", + name: "Legacy updated", + }) + ).status, + 200, + ) + assert.equal(getStored().plugins.length, 1) + assert.equal(getStored().plugins[0].id, "legacy.plugin") + const getResponse = await app.request( + "/api/admin/plugin/get?id=LEGACY.PLUGIN", + { headers: { Authorization: TOKEN } }, + env, + ) + assert.equal(getResponse.status, 200) + + const tooLong = await install(app, env, { + id: "x".repeat(256), + name: "Too long", + }) + assert.equal(tooLong.status, 400) + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "plugin update and batch save validate manifests atomically", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + const app = adminApp() + try { + assert.equal((await install(app, env, { manifest })).status, 200) + + const mismatch = await app.request( + "/api/admin/plugin/update", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: manifest.id, + manifest: { ...manifest, id: "other.plugin" }, + }), + }, + env, + ) + assert.equal(mismatch.status, 400) + assert.equal(getStored().plugins[0].version, manifest.version) + + const updated = await app.request( + "/api/admin/plugin/update", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: manifest.id, + manifest: { ...manifest, version: "2.0.0" }, + }), + }, + env, + ) + assert.equal(updated.status, 200) + assert.equal(getStored().plugins[0].version, "2.0.0") + + const duplicateBatch = await app.request( + "/api/admin/plugin/batch_save", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + plugins: [ + { id: "Legacy", name: "Legacy" }, + { id: "legacy", name: "Legacy duplicate" }, + ], + }), + }, + env, + ) + assert.equal(duplicateBatch.status, 400) + assert.equal(getStored().plugins.length, 1) + + const invalidBatch = await app.request( + "/api/admin/plugin/batch_save", + { + method: "POST", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + plugins: [{ ...getStored().plugins[0], manifest: null }], + }), + }, + env, + ) + assert.equal(invalidBatch.status, 400) + assert.equal(getStored().plugins[0].version, "2.0.0") + } finally { + __resetDbCacheForTest() + } + }, +) + +test( + "manifest URL installation uses a trusted origin and cancels rejected bodies", + { concurrency: false }, + async () => { + const { env, getStored } = setup() + const originalFetch = globalThis.fetch + const originalProcessOrigin = process.env.PLUGIN_MANIFEST_ORIGIN + let calls = 0 + try { + ;(globalThis as any).fetch = async ( + input: any, + init: any, + ): Promise => { + calls++ + assert.equal(String(input), "http://localhost/plugin.json") + assert.equal(init.redirect, "error") + assert.ok(init.signal) + return new Response(JSON.stringify(manifest), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + const installed = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(installed.status, 200) + assert.equal(getStored().plugins[0].manifest.id, manifest.id) + ;(globalThis as any).fetch = async () => { + calls++ + return new Response(JSON.stringify(manifest)) + } + const external = await install(adminApp(), env, { + manifest_url: "https://evil.example/plugin.json", + }) + assert.equal(external.status, 400) + assert.equal(calls, 1) + let oversizedCanceled = false + ;(globalThis as any).fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")) + }, + cancel() { + oversizedCanceled = true + }, + }), + { headers: { "Content-Length": "70000" } }, + ) + const oversized = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(oversized.status, 400) + assert.equal(oversizedCanceled, true) + assert.equal(getStored().plugins.length, 1) + + let streamedOversizedCanceled = false + ;(globalThis as any).fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x".repeat(40_000))) + controller.enqueue(new TextEncoder().encode("x".repeat(40_000))) + }, + cancel() { + streamedOversizedCanceled = true + }, + }), + ) + const streamedOversized = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(streamedOversized.status, 400) + assert.equal(streamedOversizedCanceled, true) + + let errorCanceled = false + ;(globalThis as any).fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("error")) + }, + cancel() { + errorCanceled = true + }, + }), + { status: 500 }, + ) + const failed = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(failed.status, 400) + assert.equal(errorCanceled, true) + + delete env.PLUGIN_MANIFEST_ORIGIN + const untrusted = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(untrusted.status, 400) + + process.env.PLUGIN_MANIFEST_ORIGIN = "http://localhost" + ;(globalThis as any).fetch = async () => + new Response(JSON.stringify(manifest), { status: 200 }) + const processOrigin = await install(adminApp(), env, { + manifest_url: "http://localhost/plugin.json", + }) + assert.equal(processOrigin.status, 200) + } finally { + globalThis.fetch = originalFetch + if (originalProcessOrigin === undefined) { + delete process.env.PLUGIN_MANIFEST_ORIGIN + } else { + process.env.PLUGIN_MANIFEST_ORIGIN = originalProcessOrigin + } + __resetDbCacheForTest() + } + }, +) + +test( + "public plugins expose only safe manifest metadata", + { concurrency: false }, + async () => { + const { env } = setup() + try { + assert.equal( + ( + await install(adminApp(), env, { + manifest, + config_values: { token: "secret" }, + script_content: "raw", + }) + ).status, + 200, + ) + const res = await publicApp().request("/api/public/plugins", {}, env) + assert.equal(res.status, 200) + const body: any = await res.json() + assert.equal(body.data.length, 1) + assert.deepEqual(body.data[0].manifest, { + apiVersion: "v1", + id: manifest.id, + version: manifest.version, + displayName: manifest.displayName, + description: manifest.description, + capabilities: manifest.capabilities, + }) + assert.equal("entry" in body.data[0].manifest, false) + assert.equal("settingsSchema" in body.data[0].manifest, false) + assert.equal("config_values" in body.data[0], false) + assert.equal("script_content" in body.data[0], false) + } finally { + __resetDbCacheForTest() + } + }, +) diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 0b41523f..380b4bf0 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -6,6 +6,7 @@ import { getStoreStatus, isDbTrusted, isEncryptionReady, + reloadDb, saveDb, } from "../internal/model/db" import { @@ -16,6 +17,7 @@ import { readDriver, readFormat, } from "../internal/model/store/backend" +import { toPublicPlugin } from "../internal/model/plugin" import { setUserPassword } from "../pkg/password" // 脱敏 / 截断 / 摘要 / 建议组装:与全局 503 拦截(index.ts)共用同一套规则 import { @@ -527,13 +529,13 @@ publicRouter.get("/offline_download_tools", (c) => { }) publicRouter.get("/plugins", async (c) => { - const db = await getDb(c.env) + const db = await reloadDb(c.env) const plugins = db.plugins || [] const activePlugins = plugins.filter((p: any) => p.enabled) return c.json({ code: 200, message: "success", - data: activePlugins, + data: activePlugins.map((plugin: any) => toPublicPlugin(plugin)), }) }) diff --git a/wrangler.jsonc b/wrangler.jsonc index 6ca4a1dd..d0323bb8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -109,6 +109,10 @@ // "ASSET_URLS": "https://cdn.jsdelivr.net/npm/@openlist-frontend/openlist-frontend@$version/dist", // "ASSET_URLS": "https://unpkg.com/@openlist-frontend/openlist-frontend@$version/dist", // + // 允许通过 manifest_url 安装插件的可信 origin,例如 https://yourdomain.com。 + // 留空禁用 URL 安装;URL 必须与该 origin 完全一致,且不允许重定向。 + // "PLUGIN_MANIFEST_ORIGIN": "https://yourdomain.com", + // // 允许作为种子数据来源的主机白名单,逗号/空格分隔 // "ALLOW_SEED": "",