mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: bring plugins, themes, approvals and branding along when moving to a shared store #1066
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user