From 3f550678db7fc0499beb4529ccd093a6347ca2b1 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 4 Jan 2026 14:26:33 +0100 Subject: [PATCH] refactor: put export endpoint in system controller --- README.md | 20 --- app/client/api-client/sdk.gen.ts | 2 +- app/client/api-client/types.gen.ts | 2 +- app/server/app.ts | 4 +- .../lifecycle/config-export.controller.ts | 155 ------------------ .../modules/lifecycle/config-export.dto.ts | 65 -------- .../modules/system/system.controller.ts | 20 ++- app/server/modules/system/system.dto.ts | 63 +++++++ app/server/modules/system/system.service.ts | 114 ++++++++++++- 9 files changed, 198 insertions(+), 247 deletions(-) delete mode 100644 app/server/modules/lifecycle/config-export.controller.ts delete mode 100644 app/server/modules/lifecycle/config-export.dto.ts diff --git a/README.md b/README.md index ddb18ea7..4510932f 100644 --- a/README.md +++ b/README.md @@ -238,26 +238,6 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true) -## Exporting configuration - -Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. - -To export, click the "Export" button in Settings. A dialog will appear with options to: - -- **Include metadata** - Include IDs, timestamps, and runtime state of entities -- **Secrets handling**: - - **Exclude** - Remove sensitive fields like passwords and API keys - - **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import) - - **Decrypt** - Export secrets as plaintext (use with caution) -- **Include recovery key** - Include the master encryption key for all repositories -- **Include password hash** - Include the hashed user passwords (enables future import workflows) - -Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported. - -Export is downloaded as JSON file that can be used for reference or future import functionality. - -> **Sensitive data handling**: Some sensitive data from earlier versions may not be encrypted in the database. Additionally, nested configuration objects within config fields are exported as-is and not processed separately. Review exported data carefully before sharing, especially when using the "Decrypt" secrets option. - ## Third-Party Software This project includes the following third-party software components: diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 37bc62e0..2b118794 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -430,7 +430,7 @@ export const downloadResticPassword = (opt * Export full configuration including all volumes, repositories, backup schedules, and notifications */ export const exportFullConfig = (options?: Options) => (options?.client ?? client).post({ - url: '/api/v1/config/export', + url: '/api/v1/system/export', ...options, headers: { 'Content-Type': 'application/json', diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 2c962600..8150cf8e 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -3345,7 +3345,7 @@ export type ExportFullConfigData = { }; path?: never; query?: never; - url: '/api/v1/config/export'; + url: '/api/v1/system/export'; }; export type ExportFullConfigErrors = { diff --git a/app/server/app.ts b/app/server/app.ts index 72d0e333..93ed52ae 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -12,7 +12,6 @@ import { volumeController } from "./modules/volumes/volume.controller"; import { backupScheduleController } from "./modules/backups/backups.controller"; import { eventsController } from "./modules/events/events.controller"; import { notificationsController } from "./modules/notifications/notifications.controller"; -import { configExportController } from "./modules/lifecycle/config-export.controller"; import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { config } from "./core/config"; @@ -61,8 +60,7 @@ export const createApp = () => { .route("/api/v1/backups", backupScheduleController) .route("/api/v1/notifications", notificationsController) .route("/api/v1/system", systemController) - .route("/api/v1/events", eventsController) - .route("/api/v1/config", configExportController); + .route("/api/v1/events", eventsController); app.get("/api/v1/openapi.json", generalDescriptor(app)); app.get("/api/v1/docs", requireAuth, scalarDescriptor); diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts deleted file mode 100644 index 1bce1ed4..00000000 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { validator } from "hono-openapi"; -import { Hono } from "hono"; -import type { Context } from "hono"; -import { - backupScheduleNotificationsTable, - backupScheduleMirrorsTable, - usersTable, - type BackupSchedule, - type BackupScheduleNotification, - type BackupScheduleMirror, - volumesTable, - repositoriesTable, - backupSchedulesTable, - notificationDestinationsTable, -} from "../../db/schema"; -import { db } from "../../db/db"; -import { logger } from "../../utils/logger"; -import { authService } from "../auth/auth.service"; -import { fullExportBodySchema, fullExportDto, type FullExportBody } from "./config-export.dto"; -import { requireAuth } from "../auth/auth.middleware"; -import { toMessage } from "~/server/utils/errors"; - -const METADATA_KEYS = { - timestamps: [ - "createdAt", - "updatedAt", - "lastBackupAt", - "nextBackupAt", - "lastHealthCheck", - "lastChecked", - "lastCopyAt", - ], - runtimeState: [ - "status", - "lastError", - "lastBackupStatus", - "lastBackupError", - "hasDownloadedResticPassword", - "lastCopyStatus", - "lastCopyError", - "sortOrder", - ], -}; - -const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; - -function filterMetadataOut>(obj: T, includeMetadata: boolean): Partial { - if (includeMetadata) { - return obj; - } - const result = { ...obj }; - for (const key of ALL_METADATA_KEYS) { - delete result[key as keyof T]; - } - return result; -} - -async function verifyExportPassword( - c: Context, - password: string, -): Promise<{ valid: true; userId: number } | { valid: false; error: string }> { - const user = c.get("user"); - if (!user) { - return { valid: false, error: "Not authenticated" }; - } - - const isValid = await authService.verifyPassword(user.id, password); - if (!isValid) { - return { valid: false, error: "Incorrect password" }; - } - - return { valid: true, userId: user.id }; -} - -async function exportEntity(entity: Record, params: FullExportBody) { - return filterMetadataOut(entity, params.includeMetadata); -} - -async function exportEntities>(entities: T[], params: FullExportBody) { - return Promise.all(entities.map((e) => exportEntity(e, params))); -} - -const transformBackupSchedules = ( - schedules: BackupSchedule[], - scheduleNotifications: BackupScheduleNotification[], - scheduleMirrors: BackupScheduleMirror[], - params: FullExportBody, -) => { - return schedules.map((schedule) => { - const assignments = scheduleNotifications - .filter((sn) => sn.scheduleId === schedule.id) - .map((sn) => filterMetadataOut(sn, params.includeMetadata)); - - const mirrors = scheduleMirrors - .filter((sm) => sm.scheduleId === schedule.id) - .map((sm) => filterMetadataOut(sm, params.includeMetadata)); - - return { - ...filterMetadataOut(schedule, params.includeMetadata), - notifications: assignments, - mirrors, - }; - }); -}; - -export const configExportController = new Hono() - .use(requireAuth) - .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { - try { - const params = c.req.valid("json"); - - const verification = await verifyExportPassword(c, params.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] = - 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(backupScheduleMirrorsTable), - db.select().from(usersTable), - ]); - - const backupSchedules = transformBackupSchedules( - backupSchedulesRaw, - scheduleNotifications, - scheduleMirrors, - params, - ); - - const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([ - exportEntities(volumes, params), - exportEntities(repositories, params), - exportEntities(notifications, params), - exportEntities(users, params), - ]); - - return c.json({ - version: 1, - exportedAt: new Date().toISOString(), - volumes: exportVolumes, - repositories: exportRepositories, - backupSchedules, - notificationDestinations: exportNotifications, - users: exportUsers, - }); - } catch (err) { - logger.error(`Config export failed: ${toMessage(err)}`); - return c.json({ error: toMessage(err) }, 500); - } - }); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts deleted file mode 100644 index abdde5cc..00000000 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type } from "arktype"; -import { describeRoute, resolver } from "hono-openapi"; - -export const fullExportBodySchema = type({ - includeMetadata: "boolean = false", - password: "string", -}); - -export type FullExportBody = typeof fullExportBodySchema.infer; - -const exportResponseSchema = type({ - version: "number", - exportedAt: "string?", - recoveryKey: "string?", - volumes: "unknown[]?", - repositories: "unknown[]?", - backupSchedules: "unknown[]?", - notificationDestinations: "unknown[]?", - users: type({ - id: "number?", - username: "string", - passwordHash: "string?", - createdAt: "number?", - updatedAt: "number?", - hasDownloadedResticPassword: "boolean?", - }) - .array() - .optional(), -}); - -const errorResponseSchema = type({ - error: "string", -}); - -export const fullExportDto = describeRoute({ - description: "Export full configuration including all volumes, repositories, backup schedules, and notifications", - operationId: "exportFullConfig", - tags: ["Config Export"], - responses: { - 200: { - description: "Full configuration export", - content: { - "application/json": { - schema: resolver(exportResponseSchema), - }, - }, - }, - 401: { - description: "Password required for export or authentication failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index 7e5aec7d..fbd1d760 100644 --- a/app/server/modules/system/system.controller.ts +++ b/app/server/modules/system/system.controller.ts @@ -3,6 +3,8 @@ import { validator } from "hono-openapi"; import { downloadResticPasswordBodySchema, downloadResticPasswordDto, + fullExportBodySchema, + fullExportDto, getUpdatesDto, systemInfoDto, type SystemInfoDto, @@ -61,4 +63,20 @@ export const systemController = new Hono() return c.json({ message: "Failed to read Restic password file" }, 500); } }, - ); + ) + .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { + const user = c.get("user"); + const body = c.req.valid("json"); + + const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id)); + + if (!dbUser) { + return c.json({ message: "User not found" }, 401); + } + + const isValid = await Bun.password.verify(body.password, dbUser.passwordHash); + + if (!isValid) { + return c.json({ message: "Incorrect password" }, 401); + } + }); diff --git a/app/server/modules/system/system.dto.ts b/app/server/modules/system/system.dto.ts index 3eccafd8..2c8c5e11 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -79,3 +79,66 @@ export const downloadResticPasswordDto = describeRoute({ }, }, }); + +export const fullExportBodySchema = type({ + includeMetadata: "boolean = false", + password: "string", +}); + +export type FullExportBody = typeof fullExportBodySchema.infer; + +const exportResponseSchema = type({ + version: "number", + exportedAt: "string?", + recoveryKey: "string?", + volumes: "unknown[]?", + repositories: "unknown[]?", + backupSchedules: "unknown[]?", + notificationDestinations: "unknown[]?", + users: type({ + id: "number?", + username: "string", + passwordHash: "string?", + createdAt: "number?", + updatedAt: "number?", + hasDownloadedResticPassword: "boolean?", + }) + .array() + .optional(), +}); + +const errorResponseSchema = type({ + error: "string", +}); + +export const fullExportDto = describeRoute({ + description: "Export full configuration including all volumes, repositories, backup schedules, and notifications", + operationId: "exportFullConfig", + tags: ["Config Export"], + responses: { + 200: { + description: "Full configuration export", + content: { + "application/json": { + schema: resolver(exportResponseSchema), + }, + }, + }, + 401: { + description: "Password required for export or authentication failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index a89b4c1f..32ebc18f 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -1,9 +1,22 @@ import { getCapabilities } from "../../core/capabilities"; import { config } from "../../core/config"; -import type { UpdateInfoDto } from "./system.dto"; +import type { FullExportBody, UpdateInfoDto } from "./system.dto"; import semver from "semver"; import { cache } from "../../utils/cache"; import { logger } from "~/server/utils/logger"; +import { db } from "~/server/db/db"; +import { + backupScheduleMirrorsTable, + backupScheduleNotificationsTable, + backupSchedulesTable, + notificationDestinationsTable, + repositoriesTable, + usersTable, + volumesTable, + type BackupScheduleMirror, + type BackupScheduleNotification, + type BackupSchedule, +} from "~/server/db/schema"; const CACHE_TTL = 60 * 60; @@ -90,7 +103,106 @@ const getUpdates = async (): Promise => { } }; +const METADATA_KEYS = { + timestamps: [ + "createdAt", + "updatedAt", + "lastBackupAt", + "nextBackupAt", + "lastHealthCheck", + "lastChecked", + "lastCopyAt", + ], + runtimeState: [ + "status", + "lastError", + "lastBackupStatus", + "lastBackupError", + "hasDownloadedResticPassword", + "lastCopyStatus", + "lastCopyError", + "sortOrder", + ], +}; + +const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; + +function filterMetadataOut>(obj: T, includeMetadata: boolean): Partial { + if (includeMetadata) { + return obj; + } + const result = { ...obj }; + for (const key of ALL_METADATA_KEYS) { + delete result[key as keyof T]; + } + return result; +} + +async function exportEntity(entity: Record, params: FullExportBody) { + return filterMetadataOut(entity, params.includeMetadata); +} + +async function exportEntities>(entities: T[], params: FullExportBody) { + return Promise.all(entities.map((e) => exportEntity(e, params))); +} + +const transformBackupSchedules = ( + schedules: BackupSchedule[], + scheduleNotifications: BackupScheduleNotification[], + scheduleMirrors: BackupScheduleMirror[], + params: FullExportBody, +) => { + return schedules.map((schedule) => { + const assignments = scheduleNotifications + .filter((sn) => sn.scheduleId === schedule.id) + .map((sn) => filterMetadataOut(sn, params.includeMetadata)); + + const mirrors = scheduleMirrors + .filter((sm) => sm.scheduleId === schedule.id) + .map((sm) => filterMetadataOut(sm, params.includeMetadata)); + + return { + ...filterMetadataOut(schedule, params.includeMetadata), + notifications: assignments, + mirrors, + }; + }); +}; + +const exportConfig = async (params: FullExportBody) => { + const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] = + 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(backupScheduleMirrorsTable), + db.select().from(usersTable), + ]); + + const backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params); + + const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([ + exportEntities(volumes, params), + exportEntities(repositories, params), + exportEntities(notifications, params), + exportEntities(users, params), + ]); + + return { + version: 1, + exportedAt: new Date().toISOString(), + volumes: exportVolumes, + repositories: exportRepositories, + backupSchedules, + notificationDestinations: exportNotifications, + users: exportUsers, + }; +}; + export const systemService = { getSystemInfo, getUpdates, + exportConfig, };