diff --git a/README.md b/README.md index b77f57ec..5239aa06 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,26 @@ 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/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index f0e1b7d8..bd87f715 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, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, 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, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, changePassword, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ChangePasswordData, ChangePasswordResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -977,3 +977,20 @@ export const downloadResticPasswordMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportFullConfig({ + ...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 a0e13c5b..085b4c1b 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, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ChangePasswordData, ChangePasswordResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -420,3 +420,15 @@ export const downloadResticPassword = (opt ...options?.headers } }); + +/** + * 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', + ...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 44b2b3c5..7f1cfc1b 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -798,7 +798,9 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -806,34 +808,44 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -853,7 +865,9 @@ export type ListRepositoriesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -879,7 +893,9 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -887,34 +903,44 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -934,7 +960,9 @@ export type CreateRepositoryData = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1022,7 +1050,9 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1030,34 +1060,44 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -1077,7 +1117,9 @@ export type GetRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1130,7 +1172,9 @@ export type UpdateRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1138,34 +1182,44 @@ export type UpdateRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -1185,7 +1239,9 @@ export type UpdateRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1455,7 +1511,9 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1463,34 +1521,44 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -1510,7 +1578,9 @@ export type ListBackupSchedulesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1719,7 +1789,9 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1727,34 +1799,44 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -1774,7 +1856,9 @@ export type GetBackupScheduleResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1964,7 +2048,9 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1972,34 +2058,44 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -2019,7 +2115,9 @@ export type GetBackupScheduleForVolumeResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2396,7 +2494,9 @@ export type GetScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2404,34 +2504,44 @@ export type GetScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -2451,7 +2561,9 @@ export type GetScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2504,7 +2616,9 @@ export type UpdateScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2512,34 +2626,44 @@ export type UpdateScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; @@ -2559,7 +2683,9 @@ export type UpdateScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -3185,3 +3311,58 @@ export type DownloadResticPasswordResponses = { }; export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses]; + +export type ExportFullConfigData = { + body?: { + password: string; + includeMetadata?: boolean; + includePasswordHash?: boolean; + includeRecoveryKey?: boolean; + secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; + }; + path?: never; + query?: never; + url: '/api/v1/config/export'; +}; + +export type ExportFullConfigErrors = { + /** + * Password required for export or authentication failed + */ + 401: { + error: string; + }; + /** + * Export failed + */ + 500: { + error: string; + }; +}; + +export type ExportFullConfigError = ExportFullConfigErrors[keyof ExportFullConfigErrors]; + +export type ExportFullConfigResponses = { + /** + * Full configuration export + */ + 200: { + version: number; + backupSchedules?: Array; + exportedAt?: string; + notificationDestinations?: Array; + recoveryKey?: string; + repositories?: Array; + users?: Array<{ + username: string; + createdAt?: number; + hasDownloadedResticPassword?: boolean; + id?: number; + passwordHash?: string; + updatedAt?: number; + }>; + volumes?: Array; + }; +}; + +export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses]; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx new file mode 100644 index 00000000..89dd2ef5 --- /dev/null +++ b/app/client/components/export-dialog.tsx @@ -0,0 +1,247 @@ +import { useMutation } from "@tanstack/react-query"; +import { Download } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { toast } from "sonner"; +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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/client/components/ui/dialog"; +import { Input } from "~/client/components/ui/input"; +import { Label } from "~/client/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; + +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" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +type BaseExportDialogProps = { + /** 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: ReactNode; + variant?: never; + size?: never; + triggerLabel?: never; + showIcon?: never; +}; + +type ExportDialogWithDefaultTrigger = BaseExportDialogProps & { + trigger?: never; + variant?: "default" | "outline" | "ghost" | "card"; + size?: "default" | "sm" | "lg" | "icon"; + triggerLabel?: string; + showIcon?: boolean; +}; + +type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger; + +export function ExportDialog({ + filename = DEFAULT_EXPORT_FILENAME, + trigger, + variant = "outline", + size = "default", + triggerLabel, + showIcon = true, +}: ExportDialogProps) { + const [open, setOpen] = 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 handleExportSuccess = (data: unknown) => { + downloadAsJson(data, filename); + 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"; + toast.error("Export failed", { + description: message, + }); + }; + + const exportMutation = useMutation({ + ...exportFullConfigMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const handleExport = (e: React.FormEvent) => { + e.preventDefault(); + if (!password) { + toast.error("Password is required"); + return; + } + + exportMutation.mutate({ + body: { + password, + includeMetadata, + secretsMode, + includeRecoveryKey, + includePasswordHash, + }, + }); + }; + + const handleDialogChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setPassword(""); + } + }; + + const defaultTrigger = + variant === "card" ? ( + + ) : ( + + ); + + return ( + + {trigger ?? defaultTrigger} + +
+ + Export Full Configuration + Export the complete Zerobyte configuration. + + +
+
+ setIncludeMetadata(checked === true)} + /> + +
+

+ 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. +

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

+ Include the hashed user passwords for seamless migration. The passwords are already securely hashed + (argon2). +

+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password to export" + required + /> +

+ Password is required to verify your identity before exporting configuration. +

+
+
+ + + + + +
+
+
+ ); +} diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index aca29aaf..a44280f9 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,5 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { Download, KeyRound, User, X } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; @@ -265,6 +266,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/server/app.ts b/app/server/app.ts index 93ed52ae..72d0e333 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -12,6 +12,7 @@ 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"; @@ -60,7 +61,8 @@ 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/events", eventsController) + .route("/api/v1/config", configExportController); app.get("/api/v1/openapi.json", generalDescriptor(app)); app.get("/api/v1/docs", requireAuth, scalarDescriptor); diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 4a97c0df..47f91e3d 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -174,6 +174,19 @@ export class AuthService { logger.info(`Password changed for user: ${user.username}`); } + + /** + * Verify password for a user (for re-authentication purposes) + */ + async verifyPassword(userId: number, password: string): Promise { + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + + if (!user) { + return false; + } + + return Bun.password.verify(password, user.passwordHash); + } } export const authService = new AuthService(); diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts new file mode 100644 index 00000000..f7e70d8c --- /dev/null +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -0,0 +1,282 @@ +import { validator } from "hono-openapi"; +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + type backupSchedulesTable, + backupScheduleNotificationsTable, + backupScheduleMirrorsTable, + usersTable, +} from "../../db/schema"; +import { db } from "../../db/db"; +import { logger } from "../../utils/logger"; +import { RESTIC_PASS_FILE, REPOSITORY_BASE } from "../../core/constants"; +import { cryptoUtils } from "../../utils/crypto"; +import { authService } from "../auth/auth.service"; +import { volumeService } from "../volumes/volume.service"; +import { repositoriesService } from "../repositories/repositories.service"; +import { notificationsService } from "../notifications/notifications.service"; +import { backupsService } from "../backups/backups.service"; +import { fullExportBodySchema, fullExportDto, type SecretsMode, type FullExportBody } from "./config-export.dto"; +import { requireAuth } from "../auth/auth.middleware"; + +type ExportParams = { + includeMetadata: boolean; + secretsMode: SecretsMode; +}; + +// Keys to exclude when metadata is not included +const METADATA_KEYS = { + ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"], + timestamps: [ + "createdAt", + "updatedAt", + "lastBackupAt", + "nextBackupAt", + "lastHealthCheck", + "lastChecked", + "lastCopyAt", + ], + runtimeState: [ + "status", + "lastError", + "lastBackupStatus", + "lastBackupError", + "hasDownloadedResticPassword", + "lastCopyStatus", + "lastCopyError", + "sortOrder", + ], +}; + +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 ALL_METADATA_KEYS) { + delete result[key as keyof T]; + } + return result; +} + +/** Parse export params from request body */ +function parseExportParamsFromBody(body: { includeMetadata?: boolean; secretsMode?: SecretsMode }): ExportParams { + const includeMetadata = body.includeMetadata === true; + const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; + return { includeMetadata, secretsMode }; +} + +/** + * Verify password for export operation. + * Requires requireAuth middleware to have already validated the session. + */ +async function verifyExportPassword( + c: Context, + password: string, +): Promise<{ valid: true; userId: number } | { valid: false; error: string }> { + // requireAuth middleware ensures c.get('user') exists + 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 }; +} + +/** + * 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 (err) { + logger.warn(`Failed to decrypt field "${key}": ${err instanceof Error ? err.message : String(err)}`); + delete result[key]; + } + } + } else if (Array.isArray(value)) { + result[key] = await Promise.all( + value.map(async (item) => + item && typeof item === "object" && !Array.isArray(item) + ? processSecrets(item as Record, secretsMode) + : item, + ), + ); + } else if (value && typeof value === "object") { + 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 = filterMetadataOut(entity, params.includeMetadata); + 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))); +} + +/** Transform backup schedules with resolved names, notifications, and mirrors */ +function transformBackupSchedules( + schedules: (typeof backupSchedulesTable.$inferSelect)[], + scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[], + scheduleMirrors: (typeof backupScheduleMirrorsTable.$inferSelect)[], + volumeMap: Map, + repoMap: Map, + notificationMap: Map, + params: ExportParams, +) { + return schedules.map((schedule) => { + const assignments = scheduleNotifications + .filter((sn) => sn.scheduleId === schedule.id) + .map((sn) => ({ + ...filterMetadataOut(sn as unknown as Record, params.includeMetadata), + name: notificationMap.get(sn.destinationId) ?? null, + })); + + const mirrors = scheduleMirrors + .filter((sm) => sm.scheduleId === schedule.id) + .map((sm) => ({ + ...filterMetadataOut(sm as unknown as Record, params.includeMetadata), + repository: repoMap.get(sm.repositoryId) ?? null, + })); + + return { + ...filterMetadataOut(schedule as Record, params.includeMetadata), + volume: volumeMap.get(schedule.volumeId) ?? null, + repository: repoMap.get(schedule.repositoryId) ?? null, + notifications: assignments, + mirrors, + }; + }); +} + +export const configExportController = new Hono() + .use(requireAuth) + .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { + try { + const body = c.req.valid("json") as FullExportBody; + + // 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); + const includeRecoveryKey = body.includeRecoveryKey === true; + const includePasswordHash = body.includePasswordHash === true; + + // Use services to fetch data + const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] = + await Promise.all([ + volumeService.listVolumes(), + repositoriesService.listRepositories(), + backupsService.listSchedules(), + notificationsService.listDestinations(), + db.select().from(backupScheduleNotificationsTable), + db.select().from(backupScheduleMirrorsTable), + 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, + scheduleMirrors, + volumeMap, + repoMap, + notificationMap, + params, + ); + + const [exportVolumes, exportRepositoriesRaw, exportNotifications] = await Promise.all([ + exportEntities(volumes, params), + exportEntities(repositories, params), + exportEntities(notifications, params), + ]); + + // Add isExistingRepository flag and path to all repository configs for import compatibility + const exportRepositories = exportRepositoriesRaw.map((repo) => { + if (!repo.config || typeof repo.config !== "object") { + return repo; + } + + const config = repo.config as Record; + const updatedConfig: Record = { ...config, isExistingRepository: true }; + + // For local repos, compute and add the full path if not already present + if (config.backend === "local" && !config.path && typeof config.name === "string") { + updatedConfig.path = `${REPOSITORY_BASE}/${config.name}`; + } + + return { ...repo, config: updatedConfig }; + }); + + let recoveryKey: string | undefined; + if (includeRecoveryKey) { + try { + recoveryKey = await Bun.file(RESTIC_PASS_FILE).text(); + logger.warn("Recovery key exported - this is a security-sensitive operation"); + } catch { + logger.warn("Could not read recovery key file"); + } + } + + // 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, + ...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}), + ...(recoveryKey ? { recoveryKey } : {}), + volumes: exportVolumes, + repositories: exportRepositories, + backupSchedules, + notificationDestinations: exportNotifications, + 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); + } + }); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts new file mode 100644 index 00000000..f7a57561 --- /dev/null +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -0,0 +1,74 @@ +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); + +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 */ + "includeRecoveryKey?": "boolean", + /** Include the user password hash */ + "includePasswordHash?": "boolean", +}); + +export type FullExportBody = typeof fullExportBodySchema.infer; +export type SecretsMode = typeof secretsModeSchema.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(), +}); + +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/utils/crypto.ts b/app/server/utils/crypto.ts index 34c3af98..d3105e97 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -186,4 +186,6 @@ const sealSecret = async (value: string): Promise => { export const cryptoUtils = { resolveSecret, sealSecret, + isEncrypted, + decrypt, };