import { Hono } from "hono"; import type { Context } from "hono"; import { eq } from "drizzle-orm"; import { backupSchedulesTable, notificationDestinationsTable, repositoriesTable, backupScheduleNotificationsTable, usersTable, volumesTable, } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { RESTIC_PASS_FILE } from "../../core/constants"; import { cryptoUtils } from "../../utils/crypto"; // ============================================================================ // Types // ============================================================================ type SecretsMode = "exclude" | "encrypted" | "cleartext"; type ExportParams = { includeIds: boolean; includeTimestamps: boolean; secretsMode: SecretsMode; excludeKeys: string[]; }; type FilterOptions = { id?: string; name?: string }; type FetchResult = { data: T[] } | { error: string; status: 400 | 404 }; // ============================================================================ // Helper Functions // ============================================================================ function omitKeys>(obj: T, keys: string[]): Partial { const result = { ...obj }; for (const key of keys) { delete result[key as keyof T]; } return result; } function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, includeRuntimeState: boolean): string[] { const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"]; const timestampKeys = ["createdAt", "updatedAt"]; // Runtime state fields (status, health checks, last backup info, etc.) const runtimeStateKeys = [ // Volume state "status", "lastError", "lastHealthCheck", // Repository state "lastChecked", // Backup schedule state "lastBackupAt", "lastBackupStatus", "lastBackupError", "nextBackupAt", ]; return [ ...(includeRuntimeState ? [] : runtimeStateKeys), ...(includeIds ? [] : idKeys), ...(includeTimestamps ? [] : timestampKeys), ]; } /** Parse common export query parameters from request */ function parseExportParams(c: Context): ExportParams { const includeIds = c.req.query("includeIds") !== "false"; const includeTimestamps = c.req.query("includeTimestamps") !== "false"; const includeRuntimeState = c.req.query("includeRuntimeState") === "true"; const secretsMode = (c.req.query("secretsMode") as SecretsMode) || "exclude"; const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeIds, includeTimestamps, secretsMode, excludeKeys }; } /** Get filter options from request query params */ function getFilterOptions(c: Context): FilterOptions { return { id: c.req.query("id"), name: c.req.query("name") }; } /** * Process secrets in an object based on the secrets mode. * Automatically detects encrypted fields using cryptoUtils.isEncrypted. */ async function processSecrets( obj: Record, secretsMode: SecretsMode ): Promise> { if (secretsMode === "encrypted") { return obj; } const result = { ...obj }; for (const [key, value] of Object.entries(result)) { if (typeof value === "string" && cryptoUtils.isEncrypted(value)) { if (secretsMode === "exclude") { delete result[key]; } else if (secretsMode === "cleartext") { try { result[key] = await cryptoUtils.decrypt(value); } catch { delete result[key]; } } } else if (value && typeof value === "object" && !Array.isArray(value)) { result[key] = await processSecrets(value as Record, secretsMode); } } return result; } /** Clean and process an entity for export */ async function exportEntity( entity: Record, params: ExportParams ): Promise> { const cleaned = omitKeys(entity, params.excludeKeys); return processSecrets(cleaned, params.secretsMode); } /** Export multiple entities */ async function exportEntities>( entities: T[], params: ExportParams ): Promise[]> { return Promise.all(entities.map((e) => exportEntity(e as Record, params))); } // ============================================================================ // Data Fetchers with Filtering // ============================================================================ async function fetchVolumes(filter: FilterOptions): Promise> { if (filter.id) { const id = Number.parseInt(filter.id, 10); if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 }; const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id)); if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 }; return { data: result }; } if (filter.name) { const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name)); if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 }; return { data: result }; } return { data: await db.select().from(volumesTable) }; } async function fetchRepositories(filter: FilterOptions): Promise> { if (filter.id) { const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id)); if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 }; return { data: result }; } if (filter.name) { const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name)); if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 }; return { data: result }; } return { data: await db.select().from(repositoriesTable) }; } async function fetchNotifications(filter: FilterOptions): Promise> { if (filter.id) { const id = Number.parseInt(filter.id, 10); if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 }; const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id)); if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 }; return { data: result }; } if (filter.name) { const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name)); if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 }; return { data: result }; } return { data: await db.select().from(notificationDestinationsTable) }; } async function fetchBackupSchedules(filter: { id?: string }): Promise> { if (filter.id) { const id = Number.parseInt(filter.id, 10); if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 }; const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id)); if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 }; return { data: result }; } return { data: await db.select().from(backupSchedulesTable) }; } /** Transform backup schedules with resolved names and notifications */ function transformBackupSchedules( schedules: typeof backupSchedulesTable.$inferSelect[], scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[], volumeMap: Map, repoMap: Map, notificationMap: Map, params: ExportParams ) { return schedules.map((schedule) => { const assignments = scheduleNotifications .filter((sn) => sn.scheduleId === schedule.id) .map((sn) => ({ ...(params.includeIds ? { destinationId: sn.destinationId } : {}), name: notificationMap.get(sn.destinationId) ?? null, notifyOnStart: sn.notifyOnStart, notifyOnSuccess: sn.notifyOnSuccess, notifyOnFailure: sn.notifyOnFailure, })); return { ...omitKeys(schedule as Record, params.excludeKeys), volume: volumeMap.get(schedule.volumeId) ?? null, repository: repoMap.get(schedule.repositoryId) ?? null, notifications: assignments, }; }); } // ============================================================================ // Controller // ============================================================================ /** * Config Export API * * Query parameters: * - includeIds: "true" | "false" (default: "true") - Include database IDs * - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt * - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only) * - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only) * - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets * - id: string (optional) - Filter by ID * - name: string (optional) - Filter by name (not for backups) */ export const configExportController = new Hono() .get("/export", async (c) => { try { const params = parseExportParams(c); const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true"; const includePasswordHash = c.req.query("includePasswordHash") === "true"; const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ db.select().from(volumesTable), db.select().from(repositoriesTable), db.select().from(backupSchedulesTable), db.select().from(notificationDestinationsTable), db.select().from(backupScheduleNotificationsTable), db.select().from(usersTable).limit(1), ]); const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); const repoMap = new Map(repositories.map((r) => [r.id, r.name])); const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); const backupSchedules = transformBackupSchedules( backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), exportEntities(notifications, params), ]); let recoveryKey: string | undefined; if (includeRecoveryKey) { try { recoveryKey = await Bun.file(RESTIC_PASS_FILE).text(); } catch { logger.warn("Could not read recovery key file"); } } return c.json({ version: 1, exportedAt: new Date().toISOString(), volumes: exportVolumes, repositories: exportRepositories, backupSchedules, notificationDestinations: exportNotifications, admin: admin ? { username: admin.username, ...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}), ...(recoveryKey ? { recoveryKey } : {}), } : null, }); } catch (err) { logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: "Failed to export config" }, 500); } }) .get("/export/volumes", async (c) => { try { const params = parseExportParams(c); const result = await fetchVolumes(getFilterOptions(c)); if ("error" in result) return c.json({ error: result.error }, result.status); // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR return c.json({ volumes: await exportEntities(result.data, params) }); } catch (err) { logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: "Failed to export volumes" }, 500); } }) .get("/export/repositories", async (c) => { try { const params = parseExportParams(c); const result = await fetchRepositories(getFilterOptions(c)); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ repositories: await exportEntities(result.data, params) }); } catch (err) { logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: "Failed to export repositories" }, 500); } }) .get("/export/notifications", async (c) => { try { const params = parseExportParams(c); const result = await fetchNotifications(getFilterOptions(c)); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ notificationDestinations: await exportEntities(result.data, params) }); } catch (err) { logger.error(`Notifications export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: "Failed to export notifications" }, 500); } }) .get("/export/backups", async (c) => { try { const params = parseExportParams(c); const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ db.select().from(volumesTable), db.select().from(repositoriesTable), db.select().from(notificationDestinationsTable), db.select().from(backupScheduleNotificationsTable), ]); const result = await fetchBackupSchedules({ id: c.req.query("id") }); if ("error" in result) return c.json({ error: result.error }, result.status); const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); const repoMap = new Map(repositories.map((r) => [r.id, r.name])); const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); const backupSchedules = transformBackupSchedules( result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); return c.json({ backupSchedules }); } catch (err) { logger.error(`Backups export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: "Failed to export backups" }, 500); } });