Compare commits

...

67 commits

Author SHA1 Message Date
Nicolas Meienberger
bb6bbcfd74 feat(export): add user password env placeholder 2026-01-04 18:36:01 +01:00
Nicolas Meienberger
1d49f59ebc refactor(config-export): do not include user's password hashes 2026-01-04 17:14:28 +01:00
Nicolas Meienberger
99ccfa4305 chore(openapi): remove error responses from schema 2026-01-04 16:29:53 +01:00
Nicolas Meienberger
64b7b93831 chore: fix linting issue 2026-01-04 16:24:18 +01:00
Nicolas Meienberger
3da653d5ad fix(export-config): missing call to service 2026-01-04 15:42:44 +01:00
Nicolas Meienberger
3f550678db refactor: put export endpoint in system controller 2026-01-04 15:02:47 +01:00
Nicolas Meienberger
081a10afeb refactor: simplify export process to minimal working concept 2026-01-04 14:18:10 +01:00
Nicolas Meienberger
20c3b8156d Merge remote-tracking branch 'upstream/main' into config-export-feature 2026-01-04 12:54:19 +01:00
Jakub Trávník
b84ae02ec6
Merge branch 'main' into config-export-feature 2026-01-02 14:38:44 +01:00
Jakub Trávník
378878f439 do not consider shortId as metadata 2025-12-31 23:35:13 +01:00
Jakub Trávník
0859fba431 feat: enhance repository export by adding isExistingRepository flag and computing path for local repos 2025-12-30 14:33:53 +01:00
Jakub Trávník
4090d5caa6 feat: add isExistingRepository flag to repository configs for import compatibility 2025-12-30 14:23:47 +01:00
Jakub Trávník
ce714f7bfc refactor: clean up imports and improve formatting in export dialog and settings components 2025-12-30 00:06:49 +01:00
Jakub Trávník
e15fff2c23
Merge branch 'main' into config-export-feature 2025-12-29 21:00:00 +01:00
Jakub Trávník
375cd10fea fix(export): add missing keys to metadata exclusion list 2025-12-23 11:09:49 +01:00
Jakub Trávník
62df1b1241 refactor(config-export): simplify auth and add mirrors support
- Use authenticated context from requireAuth middleware instead of
  duplicating session verification logic
- Remove unused cookie imports and constants (COOKIE_NAME, COOKIE_OPTIONS)
- Add backupScheduleMirrorsTable to export for complete backup config
- Include mirror-specific metadata keys (lastCopyAt, lastCopyStatus, lastCopyError)
2025-12-23 00:45:50 +01:00
Jakub Trávník
dc5c88efcd feat(auth): add authentication middleware to export controller 2025-12-23 00:26:37 +01:00
Jakub Trávník
630b34ee10
Merge branch 'main' into config-export-feature 2025-12-23 00:22:17 +01:00
Jakub Trávník
535e5c7e7d Expose isEncrypted and decrypt functions in cryptoUtils for export controller 2025-12-22 17:18:43 +01:00
Jakub Trávník
1421085660
Merge branch 'main' into config-export-feature 2025-12-22 17:12:38 +01:00
Jakub Trávník
83dfbf3212 Merge branch 'config-export-feature' of https://github.com/tvarohohlavy/zerobyte into config-export-feature 2025-12-22 17:10:33 +01:00
Jakub Trávník
512bee535a Merge remote-tracking branch 'upstream/main' into config-export-feature 2025-12-22 17:00:59 +01:00
Jakub Trávník
20861cda70 Merge remote-tracking branch 'origin/main' into config-export-feature 2025-12-22 16:53:57 +01:00
Jakub Trávník
34b9e4f6fb import related clarification 2025-12-17 17:59:06 +01:00
Jakub Trávník
1beaf406cc missing README edits 2025-12-17 17:52:18 +01:00
Jakub Trávník
6ad6044a76 Merge remote-tracking branch 'upstream/main' into config-export-feature 2025-12-17 17:41:55 +01:00
Jakub Trávník
764b64cc25 Merge remote-tracking branch 'upstream/main' into config-export-feature 2025-12-17 17:08:51 +01:00
Jakub Trávník
b55bebf8f4 Merge upstream/main into config-export-feature 2025-12-16 13:19:45 +01:00
Jakub Trávník
ded9d935ce Merge main into config-export-feature 2025-12-16 13:07:00 +01:00
Jakub Trávník
66f8ff0e50 few missing corrections related to all users export instead of just admin 2025-12-16 12:47:20 +01:00
Jakub Trávník
63f67b3b30 corrected schema for export after changes 2025-12-16 11:48:55 +01:00
Jakub Trávník
bf36f9aa56 reduced the scope of PR to MVP - exportAll only 2025-12-16 11:39:35 +01:00
Jakub Trávník
692dd5aa21 add isEncrypted check to decrypt as a safeguard 2025-12-03 23:50:51 +01:00
Jakub Trávník
a7e36e98d7 Add 401 error response for password requirement in export endpoints 2025-12-03 08:33:50 +01:00
Jakub Trávník
9f1a7694a3 fix: Entity lookup and frontend mutation patterns for config export
- Fix volumes/repositories export to prefer name lookup over ID
- Fallback to list+filter when only ID provided (services expect name)
- Refactor ExportDialog to use generated mutations instead of manual fetch
- Remove standalone /auth/verify-password endpoint (integrated into exports)
2025-12-03 00:12:34 +01:00
Jakub Trávník
2dfa15a83a remove redundant type key from exported objects 2025-12-02 11:47:26 +01:00
Jakub Trávník
13883f3053
Simplify UI message for secrets export
Co-authored-by: Nico <47644445+nicotsx@users.noreply.github.com>
2025-12-02 09:35:24 +01:00
Jakub Trávník
94712bb7b8 fix indentation 2025-12-01 23:14:22 +01:00
Jakub Trávník
fd4f031963 Better error logging for password verification failure 2025-12-01 17:45:28 +01:00
Jakub Trávník
f1e59e5931 Custom message for Full export 2025-12-01 17:44:07 +01:00
Jakub Trávník
2b6cd7af2c possibly handle nested secrets in arrrays 2025-12-01 17:15:18 +01:00
Jakub Trávník
7dab73e9bd More verbose error logging for notification and backups export errors 2025-12-01 17:10:12 +01:00
Jakub Trávník
eaca5abde5
Input validation and error handling for secretsMode param
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 17:07:24 +01:00
Jakub Trávník
7f966b504b
Explicit warning regarding volume secrets
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 17:02:50 +01:00
Jakub Trávník
709a08cfe8
Remove unrelated change
TODO: Fix Trash icon in different PR

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 17:00:51 +01:00
Jakub Trávník
c736657ffe
Error logging on repositories export failure
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 16:59:59 +01:00
Jakub Trávník
30047cbcf0 audit logging on recoveryKey export 2025-12-01 16:56:59 +01:00
Jakub Trávník
ced7a08672 fix: add type safety for ExportDialog trigger props 2025-12-01 16:51:40 +01:00
Jakub Trávník
5093699568 remove obsolete ExportCard 2025-12-01 16:43:52 +01:00
Jakub Trávník
c65d84ee9a pass id and name to export dialog 2025-12-01 16:36:17 +01:00
Jakub Trávník
11646b6187 Merge branch 'config-export-feature' of https://github.com/tvarohohlavy/zerobyte into config-export-feature 2025-12-01 16:24:35 +01:00
Jakub Trávník
a9ac6eec20 rename API endpoints for backups and notifications 2025-12-01 16:24:15 +01:00
Jakub Trávník
2306df316e
Error logging on failed to decrypt
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 16:17:05 +01:00
Jakub Trávník
07f7c5e025 Merge branch 'config-export-feature' of https://github.com/tvarohohlavy/zerobyte into config-export-feature 2025-12-01 16:14:57 +01:00
Jakub Trávník
4536f8a613 input validation in fetchRepositories 2025-12-01 16:14:43 +01:00
Jakub Trávník
f452d72050
Fix keyboard navigation in the export card
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-01 16:11:12 +01:00
Jakub Trávník
82f632f63a
Update app/client/components/export-dialog.tsx
remove unused variable

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 15:58:07 +01:00
Jakub Trávník
95342f6ec9
Update app/server/modules/lifecycle/config-export.controller.ts
More verbose error handling

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 15:55:09 +01:00
Jakub Trávník
1b874727e3
Update app/server/modules/lifecycle/config-export.controller.ts
More verbose error handling

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 15:53:12 +01:00
Jakub Trávník
c5c5da78bd
Delete unrelated change
TODO: Fix Trash icon in different PR

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 15:48:45 +01:00
Jakub Trávník
0d339d973f
Deleted unrelated change
TODO: Fix trash icon in separate PR

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 15:47:59 +01:00
Jakub Trávník
d72975e9e2 reauthentication on secrets export 2025-12-01 15:41:33 +01:00
Jakub Trávník
8e7e1694d5 more verbose error handling 2025-12-01 14:42:52 +01:00
Jakub Trávník
55e8f2e2d3 runtime state option in README 2025-12-01 11:33:56 +01:00
Jakub Trávník
94342cb48d whole export backups tile clickable 2025-12-01 11:31:19 +01:00
Jakub Trávník
3df892455f optionaly exclude runtime info 2025-12-01 11:29:39 +01:00
Jakub Trávník
2be65f6069 config export feature 2025-12-01 10:59:30 +01:00
14 changed files with 493 additions and 26 deletions

View file

@ -50,7 +50,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@ -83,7 +83,7 @@ services:
ports:
- "4096:4096"
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@ -91,6 +91,7 @@ services:
```
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)

View file

@ -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, getUpdates, 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, GetUpdatesData, GetUpdatesResponse, 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, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, 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, ExportFullConfigData, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, 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';
/**
* Register a new user
@ -995,3 +995,20 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
};
return mutationOptions;
};
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportFullConfig({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};

View file

@ -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, GetUpdatesData, GetUpdatesResponses, 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, 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, ExportFullConfigData, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, 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';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -425,3 +425,15 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
...options?.headers
}
});
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => (options?.client ?? client).post<ExportFullConfigResponses, unknown, ThrowOnError>({
url: '/api/v1/system/export',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});

View file

@ -3337,3 +3337,37 @@ export type DownloadResticPasswordResponses = {
};
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
export type ExportFullConfigData = {
body?: {
password: string;
includeMetadata?: boolean;
};
path?: never;
query?: never;
url: '/api/v1/system/export';
};
export type ExportFullConfigResponses = {
/**
* Full configuration export
*/
200: {
version: number;
backupSchedules?: Array<unknown>;
exportedAt?: string;
notificationDestinations?: Array<unknown>;
recoveryKey?: string;
repositories?: Array<unknown>;
users?: Array<{
username: string;
createdAt?: number;
hasDownloadedResticPassword?: boolean;
id?: number;
updatedAt?: number;
}>;
volumes?: Array<unknown>;
};
};
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses];

View file

@ -0,0 +1,125 @@
import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { 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 { parseError } from "../lib/errors";
import { downloadFile } from "../lib/utils";
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
export const ExportDialog = () => {
const [open, setOpen] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const [password, setPassword] = useState("");
const exportMutation = useMutation({
...exportFullConfigMutation(),
onSuccess: (data) => {
downloadFile(data, `${DEFAULT_EXPORT_FILENAME}.json`, "application/json");
toast.success("Configuration exported successfully");
setOpen(false);
setPassword("");
},
onError: (e) => {
toast.error("Export failed", {
description: parseError(e)?.message,
});
},
});
const handleExport = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
exportMutation.mutate({
body: {
password,
includeMetadata,
},
});
};
const handleDialogChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setPassword("");
}
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>
<Button>
<Download className="h-4 w-4 mr-2" />
Export configuration
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
<DialogTitle>Export Full Configuration</DialogTitle>
<DialogDescription>Export the complete Zerobyte configuration.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeMetadata"
checked={includeMetadata}
onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
/>
<Label htmlFor="includeMetadata" className="cursor-pointer">
Include metadata
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include timestamps and runtime state (status, health checks, last backup info).
</p>
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="export-password">Your Password</Label>
<Input
id="export-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password to export"
required
/>
<p className="text-xs text-muted-foreground">
Password is required to verify your identity before exporting configuration.
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={exportMutation.isPending}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -28,3 +28,20 @@ export function slugify(input: string): string {
.replace(/[_]{2,}/g, "_")
.trim();
}
type DownloadFileMimeType = "text/plain" | "application/json";
export const downloadFile = (data: unknown, filename: string, mimeType: DownloadFileMimeType = "text/plain") => {
const content = mimeType === "application/json" && typeof data !== "string" ? JSON.stringify(data, null, 2) : data;
const blob = new Blob([content as BlobPart], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

View file

@ -11,6 +11,7 @@ import { Label } from "~/client/components/ui/label";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/download-recovery-key";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { downloadFile } from "~/client/lib/utils";
export const clientMiddleware = [authMiddleware];
@ -31,16 +32,7 @@ export default function DownloadRecoveryKeyPage() {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
downloadFile(data, "restic.pass");
toast.success("Recovery key downloaded successfully!");
navigate("/volumes", { replace: true });
},

View file

@ -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";
@ -23,6 +24,7 @@ import {
downloadResticPasswordMutation,
logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { downloadFile } from "~/client/lib/utils";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
@ -78,16 +80,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
downloadFile(data, "restic.pass");
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
@ -265,6 +258,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</DialogContent>
</Dialog>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Download className="size-5" />
Export Configuration
</CardTitle>
<CardDescription className="mt-1.5">Export your Zerobyte configuration for backup or migration</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
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.
</p>
<ExportDialog />
</CardContent>
</Card>
);
}

View file

@ -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<boolean> {
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();

View file

@ -41,6 +41,7 @@ describe("system security", () => {
const endpoints: { method: string; path: string }[] = [
{ method: "GET", path: "/api/v1/system/info" },
{ method: "POST", path: "/api/v1/system/restic-password" },
{ method: "POST", path: "/api/v1/system/export" },
];
for (const { method, path } of endpoints) {
@ -86,4 +87,60 @@ describe("system security", () => {
expect(body.message).toBe("Incorrect password");
});
});
test("should return 400 for invalid payload on full export", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
test("should return 401 for incorrect password on full export", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
includeMetadata: true,
password: "wrong-password",
}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toBe("Incorrect password");
});
test("full export never exposes password hashes", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
includeMetadata: true,
password: "testpassword",
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { users?: Array<Record<string, unknown>> };
expect(Array.isArray(body.users)).toBe(true);
for (const user of body.users ?? []) {
expect(user.passwordHash).toBeUndefined();
}
});
});

View file

@ -3,6 +3,8 @@ import { validator } from "hono-openapi";
import {
downloadResticPasswordBodySchema,
downloadResticPasswordDto,
fullExportBodySchema,
fullExportDto,
getUpdatesDto,
systemInfoDto,
type SystemInfoDto,
@ -61,4 +63,24 @@ export const systemController = new Hono()
return c.json({ message: "Failed to read Restic password file" }, 500);
}
},
);
)
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
const user = c.get("user");
const { password, ...body } = c.req.valid("json");
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
if (!dbUser) {
return c.json({ message: "User not found" }, 401);
}
const isValid = await Bun.password.verify(password, dbUser.passwordHash);
if (!isValid) {
return c.json({ message: "Incorrect password" }, 401);
}
const res = await systemService.exportConfig(body);
return c.json(res);
});

View file

@ -79,3 +79,47 @@ export const downloadResticPasswordDto = describeRoute({
},
},
});
export const fullExportBodySchema = type({
includeMetadata: "boolean = false",
password: "string",
});
export type FullExportBody = typeof fullExportBodySchema.infer;
const exportResponseSchema = type({
version: "number",
exportedAt: "string?",
recoveryKey: "string?",
volumes: "unknown[]?",
repositories: "unknown[]?",
backupSchedules: "unknown[]?",
notificationDestinations: "unknown[]?",
users: type({
id: "number?",
username: "string",
createdAt: "number?",
updatedAt: "number?",
hasDownloadedResticPassword: "boolean?",
})
.array()
.optional(),
});
export type ExportFullConfigResponse = typeof exportResponseSchema.infer;
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),
},
},
},
},
});

View file

@ -4,6 +4,23 @@ import type { UpdateInfoDto } from "./system.dto";
import semver from "semver";
import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger";
import { db } from "~/server/db/db";
import {
backupScheduleMirrorsTable,
backupScheduleNotificationsTable,
backupSchedulesTable,
notificationDestinationsTable,
repositoriesTable,
usersTable,
volumesTable,
type BackupScheduleMirror,
type BackupScheduleNotification,
type BackupSchedule,
} from "~/server/db/schema";
type ExportParams = {
includeMetadata: boolean;
};
const CACHE_TTL = 60 * 60;
@ -90,7 +107,113 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
}
};
const METADATA_KEYS = {
timestamps: [
"createdAt",
"updatedAt",
"lastBackupAt",
"nextBackupAt",
"lastHealthCheck",
"lastChecked",
"lastCopyAt",
],
runtimeState: [
"status",
"lastError",
"lastBackupStatus",
"lastBackupError",
"hasDownloadedResticPassword",
"lastCopyStatus",
"lastCopyError",
"sortOrder",
],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
async function exportEntity(entity: Record<string, unknown>, params: ExportParams) {
return filterMetadataOut(entity, params.includeMetadata);
}
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: ExportParams) {
return Promise.all(entities.map((e) => exportEntity(e, params)));
}
const transformBackupSchedules = (
schedules: BackupSchedule[],
scheduleNotifications: BackupScheduleNotification[],
scheduleMirrors: BackupScheduleMirror[],
params: ExportParams,
) => {
return schedules.map((schedule) => {
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
const mirrors = scheduleMirrors
.filter((sm) => sm.scheduleId === schedule.id)
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
return {
...filterMetadataOut(schedule, params.includeMetadata),
notifications: assignments,
mirrors,
};
});
};
const exportConfig = async (params: ExportParams) => {
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(backupSchedulesTable),
db.select().from(notificationDestinationsTable),
db.select().from(backupScheduleNotificationsTable),
db.select().from(backupScheduleMirrorsTable),
db.select().from(usersTable),
]);
const backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params);
const [exportVolumes, exportRepositories, exportNotifications, exportedUsersWithHash] = await Promise.all([
exportEntities(volumes, params) as Promise<typeof volumes>,
exportEntities(repositories, params) as Promise<typeof repositories>,
exportEntities(notifications, params) as Promise<typeof notifications>,
exportEntities(users, params) as Promise<typeof users>,
]);
const exportUsers = exportedUsersWithHash.map((user) => {
const sanitizedUser = { ...user } as Record<string, unknown>;
delete sanitizedUser.passwordHash;
sanitizedUser.password = `\${USER_${user.username.toUpperCase()}_PASSWORD}`;
return sanitizedUser;
});
return {
version: 1,
exportedAt: new Date().toISOString(),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
users: exportUsers,
};
};
export const systemService = {
getSystemInfo,
getUpdates,
exportConfig,
};

View file

@ -186,4 +186,6 @@ const sealSecret = async (value: string): Promise<string> => {
export const cryptoUtils = {
resolveSecret,
sealSecret,
isEncrypted,
decrypt,
};