diff --git a/README.md b/README.md index b9732a9c..f9838551 100644 --- a/README.md +++ b/README.md @@ -198,26 +198,21 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n ## Exporting configuration -Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export: - -- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations -- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules +Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. To export, click the "Export" button on any list page or detail page. A dialog will appear with options to: -- **Include database IDs** - Useful for debugging or when you need to reference internal identifiers -- **Include timestamps** - Include createdAt/updatedAt fields in the export -- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration) -- **Secrets handling** (for repositories and notifications): +- **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** (full export only) - Include the master encryption key for all repositories -- **Include password hash** (full export only) - Include the hashed admin password for seamless migration +- **Include recovery key** - Include the master encryption key for all repositories +- **Include password hash** - Include the hashed user passwords for seamless migration -All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported. +Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported. -Exports are downloaded as JSON files that can be used for reference or future import functionality. +Export is downloaded as JSON file that can be used for reference or future import functionality. ## Propagating mounts to host diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 4114ad91..fab5ce72 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -910,71 +910,3 @@ export const exportFullConfigMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportVolumes({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export repositories configuration - */ -export const exportRepositoriesMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportRepositories({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export notification destinations configuration - */ -export const exportNotificationDestinationsMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportNotificationDestinations({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export backup schedules configuration - */ -export const exportBackupSchedulesMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportBackupSchedules({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index ea0d832f..7778eb87 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportBackupSchedulesData, ExportBackupSchedulesErrors, ExportBackupSchedulesResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, ExportNotificationDestinationsData, ExportNotificationDestinationsErrors, ExportNotificationDestinationsResponses, ExportRepositoriesData, ExportRepositoriesErrors, ExportRepositoriesResponses, ExportVolumesData, ExportVolumesErrors, ExportVolumesResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -581,59 +581,3 @@ export const exportFullConfig = (options?: } }); }; - -/** - * Export volumes configuration - */ -export const exportVolumes = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/volumes', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export repositories configuration - */ -export const exportRepositories = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/repositories', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export notification destinations configuration - */ -export const exportNotificationDestinations = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/notification-destinations', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export backup schedules configuration - */ -export const exportBackupSchedules = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/backup-schedules', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 33078a5d..efb9bb73 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2619,11 +2619,9 @@ export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[key export type ExportFullConfigData = { body?: { password: string; - includeIds?: boolean; + includeMetadata?: boolean; includePasswordHash?: boolean; includeRecoveryKey?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; }; path?: never; @@ -2668,222 +2666,3 @@ export type ExportFullConfigResponses = { }; export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses]; - -export type ExportVolumesData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/volumes'; -}; - -export type ExportVolumesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for export - */ - 401: { - error: string; - }; - /** - * Volume not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportVolumesError = ExportVolumesErrors[keyof ExportVolumesErrors]; - -export type ExportVolumesResponses = { - /** - * Volumes configuration export - */ - 200: { - volumes: Array; - }; -}; - -export type ExportVolumesResponse = ExportVolumesResponses[keyof ExportVolumesResponses]; - -export type ExportRepositoriesData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/repositories'; -}; - -export type ExportRepositoriesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for sensitive export options - */ - 401: { - error: string; - }; - /** - * Repository not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportRepositoriesError = ExportRepositoriesErrors[keyof ExportRepositoriesErrors]; - -export type ExportRepositoriesResponses = { - /** - * Repositories configuration export - */ - 200: { - repositories: Array; - }; -}; - -export type ExportRepositoriesResponse = ExportRepositoriesResponses[keyof ExportRepositoriesResponses]; - -export type ExportNotificationDestinationsData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/notification-destinations'; -}; - -export type ExportNotificationDestinationsErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for sensitive export options - */ - 401: { - error: string; - }; - /** - * Notification destination not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportNotificationDestinationsError = ExportNotificationDestinationsErrors[keyof ExportNotificationDestinationsErrors]; - -export type ExportNotificationDestinationsResponses = { - /** - * Notification destinations configuration export - */ - 200: { - notificationDestinations: Array; - }; -}; - -export type ExportNotificationDestinationsResponse = ExportNotificationDestinationsResponses[keyof ExportNotificationDestinationsResponses]; - -export type ExportBackupSchedulesData = { - body?: { - password: string; - id?: number; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/backup-schedules'; -}; - -export type ExportBackupSchedulesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for export - */ - 401: { - error: string; - }; - /** - * Backup schedule not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportBackupSchedulesError = ExportBackupSchedulesErrors[keyof ExportBackupSchedulesErrors]; - -export type ExportBackupSchedulesResponses = { - /** - * Backup schedules configuration export - */ - 200: { - backupSchedules: Array; - }; -}; - -export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses]; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 3d3a208d..36c5cdd8 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -1,14 +1,8 @@ import { useMutation } from "@tanstack/react-query"; import { Download } from "lucide-react"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { toast } from "sonner"; -import { - exportBackupSchedulesMutation, - exportFullConfigMutation, - exportNotificationDestinationsMutation, - exportRepositoriesMutation, - exportVolumesMutation, -} from "~/client/api-client/@tanstack/react-query.gen"; +import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; import { Checkbox } from "~/client/components/ui/checkbox"; import { @@ -30,7 +24,9 @@ import { SelectValue, } from "~/client/components/ui/select"; -export type SecretsMode = "exclude" | "encrypted" | "cleartext"; +type SecretsMode = "exclude" | "encrypted" | "cleartext"; + +const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config"; function downloadAsJson(data: unknown, filename: string): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); @@ -44,60 +40,14 @@ function downloadAsJson(data: unknown, filename: string): void { URL.revokeObjectURL(url); } -export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full"; - -type ExportConfig = { - label: string; - labelPlural: string; - getFilename: (id?: string | number, name?: string) => string; -}; - -const exportConfigs: Record = { - volumes: { - label: "Volume", - labelPlural: "Volumes", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `volume-${identifier}-config` : "volumes-config"; - }, - }, - repositories: { - label: "Repository", - labelPlural: "Repositories", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `repository-${identifier}-config` : "repositories-config"; - }, - }, - "notification-destinations": { - label: "Notification Destination", - labelPlural: "Notification Destinations", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config"; - }, - }, - "backup-schedules": { - label: "Backup Schedule", - labelPlural: "Backup Schedules", - getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"), - }, - full: { - label: "Full Config", - labelPlural: "Full Config", - getFilename: () => "zerobyte-full-config", - }, -}; - type BaseExportDialogProps = { - entityType: ExportEntityType; - name?: string; - id?: string | number; + /** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */ + filename?: string; }; type ExportDialogWithTrigger = BaseExportDialogProps & { /** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */ - trigger: React.ReactNode; + trigger: ReactNode; variant?: never; size?: never; triggerLabel?: never; @@ -115,9 +65,7 @@ type ExportDialogWithDefaultTrigger = BaseExportDialogProps & { type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger; export function ExportDialog({ - entityType, - name, - id, + filename = DEFAULT_EXPORT_FILENAME, trigger, variant = "outline", size = "default", @@ -125,85 +73,35 @@ export function ExportDialog({ showIcon = true, }: ExportDialogProps) { const [open, setOpen] = useState(false); - const [includeIds, setIncludeIds] = useState(true); - const [includeTimestamps, setIncludeTimestamps] = useState(true); - const [includeRuntimeState, setIncludeRuntimeState] = useState(false); + const [includeMetadata, setIncludeMetadata] = useState(false); const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false); const [secretsMode, setSecretsMode] = useState("exclude"); const [password, setPassword] = useState(""); - const config = exportConfigs[entityType]; - const isSingleItem = !!(name || id); - const isFullExport = entityType === "full"; - // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR - const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes"; - const entityLabel = isSingleItem ? config.label : config.labelPlural; - const filename = config.getFilename(id, name); - const handleExportSuccess = (data: unknown) => { downloadAsJson(data, filename); - toast.success(`${entityLabel} exported successfully`); + toast.success("Configuration exported successfully"); setOpen(false); setPassword(""); }; const handleExportError = (error: unknown) => { - const message = error && typeof error === "object" && "error" in error - ? (error as { error: string }).error - : "Unknown error"; + const message = + error && typeof error === "object" && "error" in error + ? (error as { error: string }).error + : "Unknown error"; toast.error("Export failed", { description: message, }); }; - const fullExport = useMutation({ + const exportMutation = useMutation({ ...exportFullConfigMutation(), onSuccess: handleExportSuccess, onError: handleExportError, }); - const volumesExport = useMutation({ - ...exportVolumesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const repositoriesExport = useMutation({ - ...exportRepositoriesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const notificationsExport = useMutation({ - ...exportNotificationDestinationsMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const backupSchedulesExport = useMutation({ - ...exportBackupSchedulesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const getMutation = () => { - switch (entityType) { - case "full": - return fullExport; - case "volumes": - return volumesExport; - case "repositories": - return repositoriesExport; - case "notification-destinations": - return notificationsExport; - case "backup-schedules": - return backupSchedulesExport; - } - }; - - const exportMutation = getMutation(); - const handleExport = (e: React.FormEvent) => { e.preventDefault(); if (!password) { @@ -211,60 +109,15 @@ export function ExportDialog({ return; } - const baseBody = { - password, - includeIds, - includeTimestamps, - includeRuntimeState, - secretsMode: hasSecrets ? secretsMode : undefined, - }; - - switch (entityType) { - case "full": - fullExport.mutate({ - body: { - ...baseBody, - includeRecoveryKey, - includePasswordHash, - }, - }); - break; - case "volumes": - volumesExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "repositories": - repositoriesExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "notification-destinations": - notificationsExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "backup-schedules": - backupSchedulesExport.mutate({ - body: { - ...baseBody, - id: typeof id === "number" ? id : undefined, - }, - }); - break; - } + exportMutation.mutate({ + body: { + password, + includeMetadata, + secretsMode, + includeRecoveryKey, + includePasswordHash, + }, + }); }; const handleDialogChange = (isOpen: boolean) => { @@ -276,19 +129,19 @@ export function ExportDialog({ const defaultTrigger = variant === "card" ? ( - ) : ( ); @@ -298,118 +151,83 @@ export function ExportDialog({
- Export {entityLabel} + Export Full Configuration - {isFullExport - ? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications." - : isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + Export the complete Zerobyte configuration.
setIncludeIds(checked === true)} + id="includeMetadata" + checked={includeMetadata} + onCheckedChange={(checked) => setIncludeMetadata(checked === true)} /> -

- Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. + Include database IDs, timestamps, and runtime state (status, health checks, last backup info). +

+ +
+ + +
+

+ {secretsMode === "exclude" && + "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && + "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ +
+ setIncludeRecoveryKey(checked === true)} + /> + +
+

+ ⚠️ Security sensitive: The recovery + key is the master encryption key for all repositories. Keep this export secure and never + share it.

setIncludeTimestamps(checked === true)} + id="includePasswordHash" + checked={includePasswordHash} + onCheckedChange={(checked) => setIncludePasswordHash(checked === true)} /> -

- Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. + Include the hashed admin password for seamless migration. The password is already securely + hashed (argon2).

-
- setIncludeRuntimeState(checked === true)} - /> - -
-

- Include current status, health checks, and last backup information. Usually not needed for migration. -

- - {hasSecrets && ( - <> -
- - -
-

- {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} - {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} - {secretsMode === "cleartext" && ( - - ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! - - )} -

- - )} - - {isFullExport && ( - <> -
- setIncludeRecoveryKey(checked === true)} - /> - -
-

- ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. -

- -
- setIncludePasswordHash(checked === true)} - /> - -
-

- Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). -

- - )} -
{ Edit schedule -
); diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index c3598cf6..082f475d 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -24,8 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen"; import type { Route } from "./+types/notification-details"; import { cn } from "~/client/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; -import { Bell, TestTube2, Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; +import { Bell, TestTube2 } from "lucide-react"; import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; @@ -143,7 +142,6 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP Test - )} -
- - -
+
diff --git a/app/client/modules/repositories/routes/repositories.tsx b/app/client/modules/repositories/routes/repositories.tsx index 40fc4d93..4e65bacd 100644 --- a/app/client/modules/repositories/routes/repositories.tsx +++ b/app/client/modules/repositories/routes/repositories.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { Database, Plus, RotateCcw } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { listRepositories } from "~/client/api-client/sdk.gen"; @@ -120,13 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) { )} -
- - -
+
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index b2ccc155..394b7d7b 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -25,8 +25,7 @@ import { cn } from "~/client/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; -import { Loader2, Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; +import { Loader2 } from "lucide-react"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -158,7 +157,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro "Run Doctor" )} - diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 511793d9..cd107e24 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -144,9 +144,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) { Your account details -
- -
@@ -266,6 +263,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) { + +
+ + + Export Configuration + + Export your Zerobyte configuration for backup or migration +
+ +

+ Export all your volumes, repositories, backup schedules, and notification settings to a JSON file. + This can be used to restore your configuration on a new instance or as a backup of your settings. +

+ +
); } diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index d4cf8fba..3ee690a3 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -25,8 +25,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/ import { useSystemInfo } from "~/client/hooks/use-system-info"; import { getVolume } from "~/client/api-client"; import type { VolumeStatus } from "~/client/lib/types"; -import { Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { deleteVolumeMutation, getVolumeOptions, @@ -162,7 +160,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) { > Unmount - diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 14da0b13..fc8bbda4 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { HardDrive, Plus, RotateCcw } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { EmptyState } from "~/client/components/empty-state"; @@ -130,13 +129,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { )} -
- - -
+
diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 774b9206..cb2547c6 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -1,13 +1,8 @@ import { validator } from "hono-openapi"; - import { Hono } from "hono"; import type { Context } from "hono"; import { deleteCookie, getCookie } from "hono/cookie"; -import { - backupSchedulesTable, - backupScheduleNotificationsTable, - usersTable, -} from "../../db/schema"; +import { backupSchedulesTable, backupScheduleNotificationsTable, usersTable } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { RESTIC_PASS_FILE } from "../../core/constants"; @@ -19,17 +14,9 @@ import { notificationsService } from "../notifications/notifications.service"; import { backupsService } from "../backups/backups.service"; import { fullExportBodySchema, - entityExportBodySchema, - backupScheduleExportBodySchema, fullExportDto, - volumesExportDto, - repositoriesExportDto, - notificationsExportDto, - backupSchedulesExportDto, type SecretsMode, type FullExportBody, - type EntityExportBody, - type BackupScheduleExportBody, } from "./config-export.dto"; const COOKIE_NAME = "session_id"; @@ -41,56 +28,39 @@ const COOKIE_OPTIONS = { }; type ExportParams = { - includeIds: boolean; - includeTimestamps: boolean; + includeMetadata: boolean; secretsMode: SecretsMode; - excludeKeys: string[]; }; -function omitKeys>(obj: T, keys: string[]): Partial { +// Keys to exclude when metadata is not included +const METADATA_KEYS = { + ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"], + timestamps: ["createdAt", "updatedAt", "lastBackupAt", "nextBackupAt", "lastHealthCheck", "lastChecked"], + runtimeState: ["status", "lastError", "lastBackupStatus", "lastBackupError", "hasDownloadedResticPassword"], +}; + +const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; + +/** Filter out metadata keys from an object when includeMetadata is false */ +function filterMetadataOut>(obj: T, includeMetadata: boolean): Partial { + if (includeMetadata) { + return obj; + } const result = { ...obj }; - for (const key of keys) { + for (const key of ALL_METADATA_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", - ]; - // Redundant fields that are already present inside the config object - // (e.g., type is duplicated as config.backend or config.type) - const redundantKeys = ["type"]; - return [ - ...redundantKeys, - ...(includeRuntimeState ? [] : runtimeStateKeys), - ...(includeIds ? [] : idKeys), - ...(includeTimestamps ? [] : timestampKeys), - ]; -} - /** Parse export params from request body */ function parseExportParamsFromBody(body: { - includeIds?: boolean; - includeTimestamps?: boolean; - includeRuntimeState?: boolean; + includeMetadata?: boolean; secretsMode?: SecretsMode; }): ExportParams { - const includeIds = body.includeIds !== false; - const includeTimestamps = body.includeTimestamps !== false; - const includeRuntimeState = body.includeRuntimeState === true; + const includeMetadata = body.includeMetadata === true; const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; - const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); - return { includeIds, includeTimestamps, secretsMode, excludeKeys }; + return { includeMetadata, secretsMode }; } /** @@ -167,7 +137,7 @@ async function exportEntity( entity: Record, params: ExportParams ): Promise> { - const cleaned = omitKeys(entity, params.excludeKeys); + const cleaned = filterMetadataOut(entity, params.includeMetadata); return processSecrets(cleaned, params.secretsMode); } @@ -181,8 +151,8 @@ async function exportEntities>( /** Transform backup schedules with resolved names and notifications */ function transformBackupSchedules( - schedules: typeof backupSchedulesTable.$inferSelect[], - scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[], + schedules: (typeof backupSchedulesTable.$inferSelect)[], + scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[], volumeMap: Map, repoMap: Map, notificationMap: Map, @@ -192,15 +162,12 @@ function transformBackupSchedules( const assignments = scheduleNotifications .filter((sn) => sn.scheduleId === schedule.id) .map((sn) => ({ - ...(params.includeIds ? { destinationId: sn.destinationId } : {}), + ...filterMetadataOut(sn as unknown as Record, params.includeMetadata), name: notificationMap.get(sn.destinationId) ?? null, - notifyOnStart: sn.notifyOnStart, - notifyOnSuccess: sn.notifyOnSuccess, - notifyOnFailure: sn.notifyOnFailure, })); return { - ...omitKeys(schedule as Record, params.excludeKeys), + ...filterMetadataOut(schedule as Record, params.includeMetadata), volume: volumeMap.get(schedule.volumeId) ?? null, repository: repoMap.get(schedule.repositoryId) ?? null, notifications: assignments, @@ -208,8 +175,11 @@ function transformBackupSchedules( }); } -export const configExportController = new Hono() - .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { +export const configExportController = new Hono().post( + "/export", + fullExportDto, + validator("json", fullExportBodySchema), + async (c) => { try { const body = c.req.valid("json") as FullExportBody; @@ -224,21 +194,27 @@ export const configExportController = new Hono() const includePasswordHash = body.includePasswordHash === true; // Use services to fetch data - const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ - volumeService.listVolumes(), - repositoriesService.listRepositories(), - backupsService.listSchedules(), - notificationsService.listDestinations(), - db.select().from(backupScheduleNotificationsTable), - db.select().from(usersTable).limit(1), - ]); + const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, users] = + await Promise.all([ + volumeService.listVolumes(), + repositoriesService.listRepositories(), + backupsService.listSchedules(), + notificationsService.listDestinations(), + db.select().from(backupScheduleNotificationsTable), + db.select().from(usersTable), + ]); 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 + backupSchedulesRaw, + scheduleNotifications, + volumeMap, + repoMap, + notificationMap, + params ); const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ @@ -257,194 +233,27 @@ export const configExportController = new Hono() } } + // Users need special handling for passwordHash (controlled by separate flag) + const exportUsers = (await exportEntities(users, params)).map((user) => { + if (!includePasswordHash) { + delete user.passwordHash; + } + return user; + }); + return c.json({ version: 1, - exportedAt: new Date().toISOString(), + ...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}), + ...(recoveryKey ? { recoveryKey } : {}), volumes: exportVolumes, repositories: exportRepositories, backupSchedules, notificationDestinations: exportNotifications, - admin: admin ? { - username: admin.username, - ...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}), - ...(recoveryKey ? { recoveryKey } : {}), - } : null, + users: exportUsers, }); } catch (err) { logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); } - }) - .post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let volumes; - // Prefer name over id since volumeService.getVolume expects name (slug) - if (body.name) { - try { - const result = await volumeService.getVolume(body.name); - volumes = [result.volume]; - } catch { - return c.json({ error: `Volume '${body.name}' not found` }, 404); - } - } else if (body.id !== undefined) { - // If only ID provided, find volume by numeric ID from list - const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; - if (Number.isNaN(id)) { - return c.json({ error: "Invalid volume ID" }, 400); - } - const allVolumes = await volumeService.listVolumes(); - const volume = allVolumes.find((v) => v.id === id); - if (!volume) { - return c.json({ error: `Volume with ID '${body.id}' not found` }, 404); - } - volumes = [volume]; - } else { - volumes = await volumeService.listVolumes(); - } - - return c.json({ volumes: await exportEntities(volumes, params) }); - } catch (err) { - logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let repositories; - // Prefer name over id since repositoriesService.getRepository expects name (slug) - if (body.name) { - try { - const result = await repositoriesService.getRepository(body.name); - repositories = [result.repository]; - } catch { - return c.json({ error: `Repository '${body.name}' not found` }, 404); - } - } else if (body.id !== undefined) { - // If only ID provided, find repository by ID from list - // Repository IDs are strings (UUIDs), not numeric - const allRepositories = await repositoriesService.listRepositories(); - const repository = allRepositories.find((r) => r.id === String(body.id)); - if (!repository) { - return c.json({ error: `Repository with ID '${body.id}' not found` }, 404); - } - repositories = [repository]; - } else { - repositories = await repositoriesService.listRepositories(); - } - - return c.json({ repositories: await exportEntities(repositories, params) }); - } catch (err) { - logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let notifications; - if (body.id !== undefined) { - const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; - if (Number.isNaN(id)) { - return c.json({ error: "Invalid notification destination ID" }, 400); - } - try { - const destination = await notificationsService.getDestination(id); - notifications = [destination]; - } catch { - return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404); - } - } else if (body.name) { - // notificationsService doesn't have getByName, so we list and filter - const allDestinations = await notificationsService.listDestinations(); - const destination = allDestinations.find((d) => d.name === body.name); - if (!destination) { - return c.json({ error: `Notification destination '${body.name}' not found` }, 404); - } - notifications = [destination]; - } else { - notifications = await notificationsService.listDestinations(); - } - - return c.json({ notificationDestinations: await exportEntities(notifications, params) }); - } catch (err) { - logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as BackupScheduleExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - // Get all related data for name resolution - const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ - volumeService.listVolumes(), - repositoriesService.listRepositories(), - notificationsService.listDestinations(), - db.select().from(backupScheduleNotificationsTable), - ]); - - let schedules; - if (body.id !== undefined) { - try { - const schedule = await backupsService.getSchedule(body.id); - schedules = [schedule]; - } catch { - return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404); - } - } else { - schedules = await backupsService.listSchedules(); - } - - 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( - schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params - ); - - return c.json({ backupSchedules }); - } catch (err) { - logger.error(`Backup schedules export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export backup schedules: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }); - - + } +); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index ce5d53ab..e4da4b48 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -3,47 +3,20 @@ import { describeRoute, resolver } from "hono-openapi"; const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); -const baseExportBodySchema = type({ - /** Include database IDs in export (default: true) */ - "includeIds?": "boolean", - /** Include createdAt/updatedAt timestamps (default: true) */ - "includeTimestamps?": "boolean", - /** Include runtime state like status, health checks (default: false) */ - "includeRuntimeState?": "boolean", +export const fullExportBodySchema = type({ + /** Include metadata (IDs, timestamps, runtime state) in export (default: false) */ + "includeMetadata?": "boolean", /** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */ "secretsMode?": secretsModeSchema, /** Password required for authentication */ password: "string", + /** Include the recovery key (requires password) */ + "includeRecoveryKey?": "boolean", + /** Include the admin password hash */ + "includePasswordHash?": "boolean", }); -export const fullExportBodySchema = baseExportBodySchema.and( - type({ - /** Include the recovery key (requires password) */ - "includeRecoveryKey?": "boolean", - /** Include the admin password hash */ - "includePasswordHash?": "boolean", - }) -); - -export const entityExportBodySchema = baseExportBodySchema.and( - type({ - /** Filter by ID */ - "id?": "string | number", - /** Filter by name */ - "name?": "string", - }) -); - -export const backupScheduleExportBodySchema = baseExportBodySchema.and( - type({ - /** Filter by ID */ - "id?": "number", - }) -); - export type FullExportBody = typeof fullExportBodySchema.infer; -export type EntityExportBody = typeof entityExportBodySchema.infer; -export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer; export type SecretsMode = typeof secretsModeSchema.infer; const exportResponseSchema = type({ @@ -60,22 +33,6 @@ const exportResponseSchema = type({ }).or("null"), }); -const volumesExportResponseSchema = type({ - volumes: "unknown[]", -}); - -const repositoriesExportResponseSchema = type({ - repositories: "unknown[]", -}); - -const notificationsExportResponseSchema = type({ - notificationDestinations: "unknown[]", -}); - -const backupSchedulesExportResponseSchema = type({ - backupSchedules: "unknown[]", -}); - const errorResponseSchema = type({ error: "string", }); @@ -111,195 +68,3 @@ export const fullExportDto = describeRoute({ }, }, }); - -export const volumesExportDto = describeRoute({ - description: "Export volumes configuration", - operationId: "exportVolumes", - tags: ["Config Export"], - responses: { - 200: { - description: "Volumes configuration export", - content: { - "application/json": { - schema: resolver(volumesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for export", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Volume not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const repositoriesExportDto = describeRoute({ - description: "Export repositories configuration", - operationId: "exportRepositories", - tags: ["Config Export"], - responses: { - 200: { - description: "Repositories configuration export", - content: { - "application/json": { - schema: resolver(repositoriesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for sensitive export options", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Repository not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const notificationsExportDto = describeRoute({ - description: "Export notification destinations configuration", - operationId: "exportNotificationDestinations", - tags: ["Config Export"], - responses: { - 200: { - description: "Notification destinations configuration export", - content: { - "application/json": { - schema: resolver(notificationsExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for sensitive export options", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Notification destination not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const backupSchedulesExportDto = describeRoute({ - description: "Export backup schedules configuration", - operationId: "exportBackupSchedules", - tags: ["Config Export"], - responses: { - 200: { - description: "Backup schedules configuration export", - content: { - "application/json": { - schema: resolver(backupSchedulesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for export", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Backup schedule not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -});