Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter
This commit is contained in:
parent
a7ad1f059f
commit
699b9412c1
22 changed files with 2325 additions and 9 deletions
|
|
@ -7,6 +7,28 @@ export interface RoomMapping {
|
|||
groupId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-B encrypted export bundles. All fields optional at boot: exports are
|
||||
* disabled unless every required variable is present, and an explicit
|
||||
* `ARCHIVE_EXPORT_ENABLED=false` wins over anything else.
|
||||
*/
|
||||
export interface ExportConfig {
|
||||
enabled: boolean;
|
||||
/** bech32 age1… recipients; the file key is wrapped for each. Private keys never exist here. */
|
||||
ageRecipients: string[];
|
||||
/** Rolling auto-export after this many newly archived attachments per room (0 disables). */
|
||||
every: number;
|
||||
r2: {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
/** Bounded spill dir for the ciphertext before the R2 PUT (tmpfs-friendly). */
|
||||
tmpDir: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
databaseUrl: string;
|
||||
homeserverUrl: URL;
|
||||
|
|
@ -19,6 +41,8 @@ export interface BotConfig {
|
|||
mappings: RoomMapping[];
|
||||
webdav: { baseUrl: URL; user: string; password: string };
|
||||
healthPort: number;
|
||||
/** Null when phase-B exports are not configured (bot behaves exactly as before). */
|
||||
export: ExportConfig | null;
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -84,6 +108,61 @@ export function toGroupFileMapping(mapping: RoomMapping): GroupFileMapping {
|
|||
};
|
||||
}
|
||||
|
||||
const AGE_RECIPIENT = /^age1[02-9ac-hj-np-z]{58,}$/;
|
||||
|
||||
/**
|
||||
* Parse the phase-B export block. Enabled only when the operator supplied
|
||||
* recipients AND R2 credentials; a malformed value fails the boot loudly
|
||||
* rather than silently disabling exports.
|
||||
*/
|
||||
export function parseExportConfig(env: NodeJS.ProcessEnv): ExportConfig | null {
|
||||
const rawRecipients = env.ARCHIVE_EXPORT_AGE_RECIPIENTS?.trim() ?? "";
|
||||
const accessKey = env.ARCHIVE_EXPORT_R2_ACCESS_KEY?.trim() ?? "";
|
||||
const secretKey = env.ARCHIVE_EXPORT_R2_SECRET_KEY?.trim() ?? "";
|
||||
if (env.ARCHIVE_EXPORT_ENABLED?.trim().toLowerCase() === "false") return null;
|
||||
if (rawRecipients === "" && accessKey === "" && secretKey === "") return null;
|
||||
|
||||
const recipients = rawRecipients.split(",").map((r) => r.trim()).filter((r) => r !== "");
|
||||
if (recipients.length === 0) {
|
||||
throw new ConfigError("ARCHIVE_EXPORT_AGE_RECIPIENTS: at least one bech32 age1… recipient is required");
|
||||
}
|
||||
recipients.forEach((recipient, index) => {
|
||||
if (!AGE_RECIPIENT.test(recipient)) {
|
||||
throw new ConfigError(`ARCHIVE_EXPORT_AGE_RECIPIENTS[${index}]: expected a bech32 age1… public key`);
|
||||
}
|
||||
});
|
||||
if (accessKey === "") throw new ConfigError("ARCHIVE_EXPORT_R2_ACCESS_KEY is required when export recipients are set");
|
||||
if (secretKey === "") throw new ConfigError("ARCHIVE_EXPORT_R2_SECRET_KEY is required when export recipients are set");
|
||||
|
||||
const every = env.ARCHIVE_EXPORT_EVERY === undefined || env.ARCHIVE_EXPORT_EVERY.trim() === ""
|
||||
? 500
|
||||
: Number(env.ARCHIVE_EXPORT_EVERY);
|
||||
if (!Number.isInteger(every) || every < 0 || every > 1_000_000) {
|
||||
throw new ConfigError("ARCHIVE_EXPORT_EVERY must be a non-negative integer");
|
||||
}
|
||||
|
||||
const endpoint = env.ARCHIVE_EXPORT_R2_ENDPOINT?.trim();
|
||||
if (endpoint !== undefined && endpoint !== "" && !/^https:\/\/.+/.test(endpoint)) {
|
||||
throw new ConfigError("ARCHIVE_EXPORT_R2_ENDPOINT must be an https URL");
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
ageRecipients: [...new Set(recipients)],
|
||||
every,
|
||||
r2: {
|
||||
endpoint: endpoint ?? "",
|
||||
region: env.ARCHIVE_EXPORT_R2_REGION?.trim() || "auto",
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
bucket: env.ARCHIVE_EXPORT_R2_BUCKET?.trim() || "hub-matrix-media",
|
||||
tmpDir: env.ARCHIVE_EXPORT_TMP_DIR?.trim() || DEFAULT_EXPORT_TMP_DIR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPORT_TMP_DIR = `${process.env.TMPDIR ?? "/tmp"}/archive-bot-export`;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): BotConfig {
|
||||
const healthPort = Number(env.ARCHIVE_BOT_HEALTH_PORT ?? 8085);
|
||||
if (!Number.isInteger(healthPort) || healthPort <= 0 || healthPort > 65535) {
|
||||
|
|
@ -106,5 +185,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): BotConfig {
|
|||
password: required(env, "FILES_OCID_PASSWORD"),
|
||||
},
|
||||
healthPort,
|
||||
export: parseExportConfig(env),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue