190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
import type { GroupFileMapping } from "@tactical-media-broadcast-hub/core";
|
|
|
|
/** Explicit, immutable room → library mapping. Only rooms listed here are ever read. */
|
|
export interface RoomMapping {
|
|
roomId: string;
|
|
workspaceId: string;
|
|
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;
|
|
matrixUser: string;
|
|
matrixPassword: string;
|
|
/** Stable device so Matrix transaction-ID idempotency survives restarts. */
|
|
matrixDeviceId: string;
|
|
/** Extra hosts (e.g. the R2 media endpoint) media redirects may target; auth is always dropped. */
|
|
mediaRedirectHosts: string[];
|
|
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;
|
|
/** Room IDs only: `!opaque:server`. Aliases (`#…`) are rejected — mappings must be immutable. */
|
|
const ROOM_ID = /^![^:\s]+:[^\s]+$/;
|
|
|
|
export class ConfigError extends Error {}
|
|
|
|
function required(env: NodeJS.ProcessEnv, name: string): string {
|
|
const value = env[name];
|
|
if (value === undefined || value.trim() === "") throw new ConfigError(`${name} is required`);
|
|
return value.trim();
|
|
}
|
|
|
|
function httpUrl(name: string, raw: string): URL {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw);
|
|
} catch {
|
|
throw new ConfigError(`${name} must be an absolute URL`);
|
|
}
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new ConfigError(`${name} must be http(s)`);
|
|
return url;
|
|
}
|
|
|
|
export function parseRoomMappings(raw: string): RoomMapping[] {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new ConfigError("ROOM_MAPPINGS must be JSON");
|
|
}
|
|
if (!Array.isArray(parsed) || parsed.length === 0) throw new ConfigError("ROOM_MAPPINGS must be a non-empty array");
|
|
const seen = new Set<string>();
|
|
return parsed.map((entry: unknown, index) => {
|
|
if (typeof entry !== "object" || entry === null) throw new ConfigError(`ROOM_MAPPINGS[${index}] must be an object`);
|
|
const { roomId, workspaceId, groupId } = entry as Record<string, unknown>;
|
|
if (typeof roomId !== "string" || !ROOM_ID.test(roomId)) {
|
|
throw new ConfigError(`ROOM_MAPPINGS[${index}].roomId must be a room ID (!id:server), not an alias`);
|
|
}
|
|
if (typeof workspaceId !== "string" || !UUID.test(workspaceId)) {
|
|
throw new ConfigError(`ROOM_MAPPINGS[${index}].workspaceId must be a UUID`);
|
|
}
|
|
if (typeof groupId !== "string" || !UUID.test(groupId)) {
|
|
throw new ConfigError(`ROOM_MAPPINGS[${index}].groupId must be a UUID`);
|
|
}
|
|
if (seen.has(roomId)) throw new ConfigError(`ROOM_MAPPINGS has duplicate roomId ${roomId}`);
|
|
seen.add(roomId);
|
|
return { roomId, workspaceId: workspaceId.toLowerCase(), groupId: groupId.toLowerCase() };
|
|
});
|
|
}
|
|
|
|
/** The env mapping only exists for rooms explicitly enabled as unencrypted archive rooms. */
|
|
export function toGroupFileMapping(mapping: RoomMapping): GroupFileMapping {
|
|
return {
|
|
workspaceId: mapping.workspaceId,
|
|
groupId: mapping.groupId,
|
|
roomId: mapping.roomId,
|
|
spaceId: mapping.workspaceId,
|
|
folderId: `${mapping.workspaceId}/${mapping.groupId}`,
|
|
archivePolicy: "unencrypted-archive",
|
|
retention: { legalHold: false, mode: "keep" },
|
|
};
|
|
}
|
|
|
|
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) {
|
|
throw new ConfigError("ARCHIVE_BOT_HEALTH_PORT must be a TCP port");
|
|
}
|
|
return {
|
|
databaseUrl: required(env, "DATABASE_URL"),
|
|
homeserverUrl: httpUrl("MATRIX_HOMESERVER_URL", required(env, "MATRIX_HOMESERVER_URL")),
|
|
matrixUser: required(env, "MATRIX_USER"),
|
|
matrixPassword: required(env, "MATRIX_PASSWORD"),
|
|
matrixDeviceId: env.MATRIX_DEVICE_ID?.trim() || "HUB_ARCHIVE_BOT",
|
|
mediaRedirectHosts: (env.MATRIX_MEDIA_REDIRECT_HOSTS ?? "")
|
|
.split(",")
|
|
.map((h) => h.trim().toLowerCase())
|
|
.filter((h) => h !== ""),
|
|
mappings: parseRoomMappings(required(env, "ROOM_MAPPINGS")),
|
|
webdav: {
|
|
baseUrl: httpUrl("FILES_OCID_WEBDAV_URL", required(env, "FILES_OCID_WEBDAV_URL")),
|
|
user: required(env, "FILES_OCID_USER"),
|
|
password: required(env, "FILES_OCID_PASSWORD"),
|
|
},
|
|
healthPort,
|
|
export: parseExportConfig(env),
|
|
};
|
|
}
|