mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
import { chmod, readFile, writeFile } from 'node:fs/promises';
|
|
import type { ImportStep } from '../import';
|
|
import { exportCollection, type ExportStep } from '../export';
|
|
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 { ensureConfigDir, getConfigPath, isConfigReadOnly } 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;
|
|
},
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The way back (SHARED_STORE_EXPORT, lib/shared-store/export.ts): the same
|
|
* items from the store into the local layout. Run after the admin-state
|
|
* export, so directory-keyrings.json ends up owner-only as local mode
|
|
* writes it. The stored signing key replaces a key file on the volume, as
|
|
* every other file of the same name is replaced: the exported signatures
|
|
* were made with it.
|
|
*/
|
|
export const exportSteps: ExportStep[] = [
|
|
exportCollection(PLUGINS_COLLECTION, pluginsOptions),
|
|
exportCollection(THEMES_COLLECTION, themesOptions),
|
|
exportCollection(PLUGIN_CONFIG_COLLECTION, pluginConfigOptions),
|
|
exportCollection(PLUGIN_APPROVALS_COLLECTION, pluginApprovalsOptions),
|
|
exportCollection(BRANDING_COLLECTION, brandingOptions),
|
|
{
|
|
name: 'directory-keyrings',
|
|
async run() {
|
|
const doc = await getStore().collection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions).get(DIRECTORY_KEYRINGS_ID);
|
|
if (!doc) return 0;
|
|
await localCollection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions).put(DIRECTORY_KEYRINGS_ID, doc.data);
|
|
return 1;
|
|
},
|
|
},
|
|
{
|
|
name: 'plugin-signing-key',
|
|
async run() {
|
|
if (isConfigReadOnly()) return 0;
|
|
const pem = await getStore().kv.get(SHARED_KEY_NAME);
|
|
if (!pem) return 0;
|
|
await ensureConfigDir();
|
|
const file = getConfigPath('plugin-signing.key');
|
|
await writeFile(file, pem, { encoding: 'utf-8', mode: 0o600 });
|
|
await chmod(file, 0o600).catch(() => {});
|
|
return 1;
|
|
},
|
|
},
|
|
];
|