From ad7a8d713ce31e67c1b99ce65df8bca7a065a2fc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:27:17 +0100 Subject: [PATCH] feat: bring plugins, themes, approvals and branding along when moving to a shared store #1066 --- lib/shared-store/__tests__/import-ext.test.ts | 125 ++++++++++++++++++ lib/shared-store/import/ext.ts | 87 ++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 lib/shared-store/__tests__/import-ext.test.ts create mode 100644 lib/shared-store/import/ext.ts diff --git a/lib/shared-store/__tests__/import-ext.test.ts b/lib/shared-store/__tests__/import-ext.test.ts new file mode 100644 index 000000000..33d27badd --- /dev/null +++ b/lib/shared-store/__tests__/import-ext.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Moving a single-instance install to a shared store (#1066): plugins and +// themes with their bundles, signatures and history, plugin config, +// approvals and their packages, the signing key, the keyring dates and the +// branding uploads come along; a second run writes nothing, and the local +// files stay as they were. + +import { steps } from '../import/ext'; +import { importLocalData } from '../import'; +import { getStore, setStoreForTests } from '../index'; +import { resetLocalStoreForTests } from '../local'; +import { separateFakeStore } from '@/lib/admin/__tests__/shared-store-fake'; +import { + brandingDocs, + directoryKeyringDocs, + pluginApprovalDocs, + pluginConfigDocs, + pluginDocs, + themeDocs, +} from '@/lib/admin/extension-store'; + +let dir: string; +const pem = generateKeyPairSync('ed25519').privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + +const FILES: Record = { + 'config/plugins/registry.json': JSON.stringify({ plugins: [{ id: 'demo', version: '1.1.0' }] }, null, 2), + 'config/plugins/demo.js': 'v2()', + 'config/plugins/demo.sig': 'c2ln', + 'config/plugins/history/demo/1.0.0/entry.json': JSON.stringify({ version: '1.0.0' }), + 'config/plugins/history/demo/1.0.0/bundle.js': 'v1()', + 'config/themes/registry.json': JSON.stringify({ themes: [{ id: 'dark' }] }, null, 2), + 'config/themes/dark.css': ':root{}', + 'config/themes/dark.skin.css': '.skin{}', + 'config/plugin-config/demo.json': JSON.stringify({ apiKey: 'k' }, null, 2), + 'config/plugin-approvals.json': JSON.stringify({ entries: [] }, null, 2), + [`config/plugin-approvals/upload-${'a'.repeat(64)}.zip`]: Buffer.from([0x50, 0x4b, 3, 4]), + 'config/branding/faviconUrl.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]), + 'config/plugin-signing.key': pem, + 'config/config.json': JSON.stringify({ appName: 'not part of ext' }), + 'state/directory-keyrings.json': JSON.stringify({ 'https://extensions.bulwarkmail.org': '2026-10-20T00:00:00.000Z' }), +}; + +/** Every local file with its bytes and mtime, to show the import changed none. */ +function snapshot(): Record { + const out: Record = {}; + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) walk(full); + else out[path.relative(dir, full)] = `${createHash('sha256').update(readFileSync(full)).digest('hex')} ${statSync(full).mtimeMs}`; + } + }; + walk(path.join(dir, 'config')); + walk(path.join(dir, 'state')); + return out; +} + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-import-ext-')); + vi.stubEnv('ADMIN_CONFIG_DIR', path.join(dir, 'config')); + vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state')); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', ''); + for (const [file, content] of Object.entries(FILES)) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), content); + } + resetLocalStoreForTests(); + setStoreForTests(separateFakeStore(path.join(dir, 'store'))); +}); + +afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +const text = async (docs: { get(id: string): Promise<{ data: Buffer } | null> }, id: string) => (await docs.get(id))?.data.toString('utf-8'); + +describe('importing plugins, themes and branding into the shared store', () => { + it('copies every item once and leaves the local files alone', async () => { + const before = snapshot(); + const first = await importLocalData(steps); + expect(first.imported).toEqual({ + plugins: 5, + themes: 3, + 'plugin-config': 1, + 'plugin-approvals': 2, + branding: 1, + 'directory-keyrings': 1, + 'plugin-signing-key': 1, + }); + + expect(await text(pluginDocs(), 'demo.js')).toBe('v2()'); + expect(await text(pluginDocs(), 'history/demo/1.0.0/bundle.js')).toBe('v1()'); + expect(await text(themeDocs(), 'dark.skin.css')).toBe('.skin{}'); + expect(JSON.parse((await text(pluginConfigDocs(), 'demo.json'))!)).toEqual({ apiKey: 'k' }); + expect((await pluginApprovalDocs().get(`plugin-approvals/upload-${'a'.repeat(64)}.zip`))?.data).toEqual(Buffer.from([0x50, 0x4b, 3, 4])); + expect((await brandingDocs().get('faviconUrl.png'))?.data).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + expect(await text(directoryKeyringDocs(), 'directory-keyrings.json')).toContain('2026-10-20'); + expect(await getStore().kv.get('plugin-signing-key')).toBe(pem); + // Nothing outside this part came along. + expect(await pluginApprovalDocs().get('config.json')).toBeNull(); + + const second = await importLocalData(steps); + expect(Object.values(second.imported).every((n) => n === 0)).toBe(true); + expect(snapshot()).toEqual(before); + }); + + it('keeps what the store already holds', async () => { + await pluginDocs().put('demo.js', 'already shared()'); + await getStore().kv.set('plugin-signing-key', 'stored key'); + await importLocalData(steps); + expect(await text(pluginDocs(), 'demo.js')).toBe('already shared()'); + expect(await getStore().kv.get('plugin-signing-key')).toBe('stored key'); + }); +}); diff --git a/lib/shared-store/import/ext.ts b/lib/shared-store/import/ext.ts new file mode 100644 index 000000000..ec3ab0eb7 --- /dev/null +++ b/lib/shared-store/import/ext.ts @@ -0,0 +1,87 @@ +import { readFile } from 'node:fs/promises'; +import type { ImportStep } from '../import'; +import { getStore, type CollectionOptions } from '../index'; +import { localCollection } from '../local'; +import { + BRANDING_COLLECTION, + DIRECTORY_KEYRINGS_ID, + PLUGIN_APPROVALS_COLLECTION, + PLUGIN_CONFIG_COLLECTION, + PLUGINS_COLLECTION, + THEMES_COLLECTION, + brandingOptions, + directoryKeyringsOptions, + pluginApprovalsOptions, + pluginConfigOptions, + pluginsOptions, + themesOptions, +} from '@/lib/admin/extension-store'; +import { ADMIN_STATE_COLLECTION } from '@/lib/admin/admin-store'; +import { SHARED_KEY_NAME } from '@/lib/admin/plugin-signing'; +import { getConfigPath } from '@/lib/admin/paths'; + +/** + * Part `ext` of the import (#1066): installed plugins and themes (registry, + * bundles, signatures, CSS, kept history), plugin config, plugin approvals + * and the packages sent with them, the plugin signing key, the directory + * keyring dates and the branding uploads of a single-instance install. + * Reads the local files as local mode does and writes only what the shared + * store doesn't hold yet. With ADMIN_CONFIG_READONLY the config-dir + * collections are the mounted files on every replica, so nothing of them is + * copied (the put finds the file already there). + */ + +/** Every document under `prefix` of a collection. */ +function copyCollection(name: string, options: CollectionOptions, prefix = '', only?: (id: string) => boolean): ImportStep { + return { + name, + async run() { + const local = localCollection(name, options); + const shared = getStore().collection(name, options); + let written = 0; + for (const id of await local.list(prefix)) { + if (only && !only(id)) continue; + const doc = await local.get(id); + if (!doc) continue; + if ((await shared.put(id, doc.data, { ifVersion: null })).ok) written++; + } + return written; + }, + }; +} + +export const steps: ImportStep[] = [ + copyCollection(PLUGINS_COLLECTION, pluginsOptions), + copyCollection(THEMES_COLLECTION, themesOptions), + copyCollection(PLUGIN_CONFIG_COLLECTION, pluginConfigOptions, '', (id) => id.endsWith('.json') && !id.includes('/')), + // plugin-approvals.json and plugin-approvals/-.zip, both under C. + copyCollection(PLUGIN_APPROVALS_COLLECTION, pluginApprovalsOptions, 'plugin-approvals', + (id) => id === 'plugin-approvals.json' || /^plugin-approvals\/[^/]+\.zip$/.test(id)), + copyCollection(BRANDING_COLLECTION, brandingOptions, '', (id) => !id.includes('/')), + { + name: 'directory-keyrings', + async run() { + const doc = await localCollection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions).get(DIRECTORY_KEYRINGS_ID); + if (!doc) return 0; + const shared = getStore().collection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions); + return (await shared.put(DIRECTORY_KEYRINGS_ID, doc.data, { ifVersion: null })).ok ? 1 : 0; + }, + }, + { + // The generated key in C/plugin-signing.key, so bundles signed before + // the move keep verifying. PLUGIN_SIGNING_KEY_FILE, when set, still wins + // over the stored key on every replica. + name: 'plugin-signing-key', + async run() { + let pem: string; + try { + pem = await readFile(getConfigPath('plugin-signing.key'), 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + if (!pem.includes('-----BEGIN PRIVATE KEY-----')) return 0; + return (await getStore().kv.set(SHARED_KEY_NAME, pem, { ifAbsent: true })) ? 1 : 0; + }, + }, +];