feat: bring plugins, themes, approvals and branding along when moving to a shared store #1066

This commit is contained in:
Linus Rath
2026-11-01 00:27:17 +01:00
parent 3c34eda260
commit ad7a8d713c
2 changed files with 212 additions and 0 deletions
@@ -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<string, string | Buffer> = {
'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<string, string> {
const out: Record<string, string> = {};
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');
});
});
+87
View File
@@ -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/<id>-<hash>.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;
},
},
];