reduced the scope of PR to MVP - exportAll only

This commit is contained in:
Jakub Trávník 2025-12-16 11:39:35 +01:00
parent 692dd5aa21
commit bf36f9aa56
16 changed files with 189 additions and 1162 deletions

View file

@ -198,26 +198,21 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
## Exporting configuration
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export:
- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations
- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes.
To export, click the "Export" button on any list page or detail page. A dialog will appear with options to:
- **Include database IDs** - Useful for debugging or when you need to reference internal identifiers
- **Include timestamps** - Include createdAt/updatedAt fields in the export
- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration)
- **Secrets handling** (for repositories and notifications):
- **Include metadata** - Include IDs, timestamps, and runtime state of entities
- **Secrets handling**:
- **Exclude** - Remove sensitive fields like passwords and API keys
- **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import)
- **Decrypt** - Export secrets as plaintext (use with caution)
- **Include recovery key** (full export only) - Include the master encryption key for all repositories
- **Include password hash** (full export only) - Include the hashed admin password for seamless migration
- **Include recovery key** - Include the master encryption key for all repositories
- **Include password hash** - Include the hashed user passwords for seamless migration
All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
Exports are downloaded as JSON files that can be used for reference or future import functionality.
Export is downloaded as JSON file that can be used for reference or future import functionality.
## Propagating mounts to host

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, deleteVolume, doctorRepository, downloadResticPassword, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
@ -910,71 +910,3 @@ export const exportFullConfigMutation = (options?: Partial<Options<ExportFullCon
};
return mutationOptions;
};
/**
* Export volumes configuration
*/
export const exportVolumesMutation = (options?: Partial<Options<ExportVolumesData>>): UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> => {
const mutationOptions: UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportVolumes({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export repositories configuration
*/
export const exportRepositoriesMutation = (options?: Partial<Options<ExportRepositoriesData>>): UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> => {
const mutationOptions: UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportRepositories({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export notification destinations configuration
*/
export const exportNotificationDestinationsMutation = (options?: Partial<Options<ExportNotificationDestinationsData>>): UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> => {
const mutationOptions: UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportNotificationDestinations({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export backup schedules configuration
*/
export const exportBackupSchedulesMutation = (options?: Partial<Options<ExportBackupSchedulesData>>): UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> => {
const mutationOptions: UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportBackupSchedules({
...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, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportBackupSchedulesData, ExportBackupSchedulesErrors, ExportBackupSchedulesResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, ExportNotificationDestinationsData, ExportNotificationDestinationsErrors, ExportNotificationDestinationsResponses, ExportRepositoriesData, ExportRepositoriesErrors, ExportRepositoriesResponses, ExportVolumesData, ExportVolumesErrors, ExportVolumesResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -581,59 +581,3 @@ export const exportFullConfig = <ThrowOnError extends boolean = false>(options?:
}
});
};
/**
* Export volumes configuration
*/
export const exportVolumes = <ThrowOnError extends boolean = false>(options?: Options<ExportVolumesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportVolumesResponses, ExportVolumesErrors, ThrowOnError>({
url: '/api/v1/config/export/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export repositories configuration
*/
export const exportRepositories = <ThrowOnError extends boolean = false>(options?: Options<ExportRepositoriesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportRepositoriesResponses, ExportRepositoriesErrors, ThrowOnError>({
url: '/api/v1/config/export/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export notification destinations configuration
*/
export const exportNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ExportNotificationDestinationsData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportNotificationDestinationsResponses, ExportNotificationDestinationsErrors, ThrowOnError>({
url: '/api/v1/config/export/notification-destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export backup schedules configuration
*/
export const exportBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ExportBackupSchedulesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportBackupSchedulesResponses, ExportBackupSchedulesErrors, ThrowOnError>({
url: '/api/v1/config/export/backup-schedules',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};

View file

@ -2619,11 +2619,9 @@ export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[key
export type ExportFullConfigData = {
body?: {
password: string;
includeIds?: boolean;
includeMetadata?: boolean;
includePasswordHash?: boolean;
includeRecoveryKey?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
@ -2668,222 +2666,3 @@ export type ExportFullConfigResponses = {
};
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses];
export type ExportVolumesData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/volumes';
};
export type ExportVolumesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for export
*/
401: {
error: string;
};
/**
* Volume not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportVolumesError = ExportVolumesErrors[keyof ExportVolumesErrors];
export type ExportVolumesResponses = {
/**
* Volumes configuration export
*/
200: {
volumes: Array<unknown>;
};
};
export type ExportVolumesResponse = ExportVolumesResponses[keyof ExportVolumesResponses];
export type ExportRepositoriesData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/repositories';
};
export type ExportRepositoriesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for sensitive export options
*/
401: {
error: string;
};
/**
* Repository not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportRepositoriesError = ExportRepositoriesErrors[keyof ExportRepositoriesErrors];
export type ExportRepositoriesResponses = {
/**
* Repositories configuration export
*/
200: {
repositories: Array<unknown>;
};
};
export type ExportRepositoriesResponse = ExportRepositoriesResponses[keyof ExportRepositoriesResponses];
export type ExportNotificationDestinationsData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/notification-destinations';
};
export type ExportNotificationDestinationsErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for sensitive export options
*/
401: {
error: string;
};
/**
* Notification destination not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportNotificationDestinationsError = ExportNotificationDestinationsErrors[keyof ExportNotificationDestinationsErrors];
export type ExportNotificationDestinationsResponses = {
/**
* Notification destinations configuration export
*/
200: {
notificationDestinations: Array<unknown>;
};
};
export type ExportNotificationDestinationsResponse = ExportNotificationDestinationsResponses[keyof ExportNotificationDestinationsResponses];
export type ExportBackupSchedulesData = {
body?: {
password: string;
id?: number;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/backup-schedules';
};
export type ExportBackupSchedulesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for export
*/
401: {
error: string;
};
/**
* Backup schedule not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportBackupSchedulesError = ExportBackupSchedulesErrors[keyof ExportBackupSchedulesErrors];
export type ExportBackupSchedulesResponses = {
/**
* Backup schedules configuration export
*/
200: {
backupSchedules: Array<unknown>;
};
};
export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses];

View file

@ -1,14 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import {
exportBackupSchedulesMutation,
exportFullConfigMutation,
exportNotificationDestinationsMutation,
exportRepositoriesMutation,
exportVolumesMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import {
@ -30,7 +24,9 @@ import {
SelectValue,
} from "~/client/components/ui/select";
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
type SecretsMode = "exclude" | "encrypted" | "cleartext";
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
function downloadAsJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
@ -44,60 +40,14 @@ function downloadAsJson(data: unknown, filename: string): void {
URL.revokeObjectURL(url);
}
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
type ExportConfig = {
label: string;
labelPlural: string;
getFilename: (id?: string | number, name?: string) => string;
};
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
volumes: {
label: "Volume",
labelPlural: "Volumes",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `volume-${identifier}-config` : "volumes-config";
},
},
repositories: {
label: "Repository",
labelPlural: "Repositories",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `repository-${identifier}-config` : "repositories-config";
},
},
"notification-destinations": {
label: "Notification Destination",
labelPlural: "Notification Destinations",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
},
},
"backup-schedules": {
label: "Backup Schedule",
labelPlural: "Backup Schedules",
getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"),
},
full: {
label: "Full Config",
labelPlural: "Full Config",
getFilename: () => "zerobyte-full-config",
},
};
type BaseExportDialogProps = {
entityType: ExportEntityType;
name?: string;
id?: string | number;
/** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */
filename?: string;
};
type ExportDialogWithTrigger = BaseExportDialogProps & {
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
trigger: React.ReactNode;
trigger: ReactNode;
variant?: never;
size?: never;
triggerLabel?: never;
@ -115,9 +65,7 @@ type ExportDialogWithDefaultTrigger = BaseExportDialogProps & {
type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger;
export function ExportDialog({
entityType,
name,
id,
filename = DEFAULT_EXPORT_FILENAME,
trigger,
variant = "outline",
size = "default",
@ -125,85 +73,35 @@ export function ExportDialog({
showIcon = true,
}: ExportDialogProps) {
const [open, setOpen] = useState(false);
const [includeIds, setIncludeIds] = useState(true);
const [includeTimestamps, setIncludeTimestamps] = useState(true);
const [includeRuntimeState, setIncludeRuntimeState] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [password, setPassword] = useState("");
const config = exportConfigs[entityType];
const isSingleItem = !!(name || id);
const isFullExport = entityType === "full";
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural;
const filename = config.getFilename(id, name);
const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename);
toast.success(`${entityLabel} exported successfully`);
toast.success("Configuration exported successfully");
setOpen(false);
setPassword("");
};
const handleExportError = (error: unknown) => {
const message = error && typeof error === "object" && "error" in error
? (error as { error: string }).error
: "Unknown error";
const message =
error && typeof error === "object" && "error" in error
? (error as { error: string }).error
: "Unknown error";
toast.error("Export failed", {
description: message,
});
};
const fullExport = useMutation({
const exportMutation = useMutation({
...exportFullConfigMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const volumesExport = useMutation({
...exportVolumesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const repositoriesExport = useMutation({
...exportRepositoriesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const notificationsExport = useMutation({
...exportNotificationDestinationsMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const backupSchedulesExport = useMutation({
...exportBackupSchedulesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const getMutation = () => {
switch (entityType) {
case "full":
return fullExport;
case "volumes":
return volumesExport;
case "repositories":
return repositoriesExport;
case "notification-destinations":
return notificationsExport;
case "backup-schedules":
return backupSchedulesExport;
}
};
const exportMutation = getMutation();
const handleExport = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
@ -211,60 +109,15 @@ export function ExportDialog({
return;
}
const baseBody = {
password,
includeIds,
includeTimestamps,
includeRuntimeState,
secretsMode: hasSecrets ? secretsMode : undefined,
};
switch (entityType) {
case "full":
fullExport.mutate({
body: {
...baseBody,
includeRecoveryKey,
includePasswordHash,
},
});
break;
case "volumes":
volumesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "repositories":
repositoriesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "notification-destinations":
notificationsExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "backup-schedules":
backupSchedulesExport.mutate({
body: {
...baseBody,
id: typeof id === "number" ? id : undefined,
},
});
break;
}
exportMutation.mutate({
body: {
password,
includeMetadata,
secretsMode,
includeRecoveryKey,
includePasswordHash,
},
});
};
const handleDialogChange = (isOpen: boolean) => {
@ -276,19 +129,19 @@ export function ExportDialog({
const defaultTrigger =
variant === "card" ? (
<button
<button
type="button"
className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full border-0 bg-transparent p-0 hover:opacity-80 transition-opacity"
>
<Download className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
{triggerLabel ?? "Export Config"}
</span>
</button>
) : (
<Button variant={variant} size={size}>
{showIcon && <Download className="h-4 w-4 mr-2" />}
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
{triggerLabel ?? "Export Config"}
</Button>
);
@ -298,118 +151,83 @@ export function ExportDialog({
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogTitle>Export Full Configuration</DialogTitle>
<DialogDescription>
{isFullExport
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
: isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
Export the complete Zerobyte configuration.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeIds"
checked={includeIds}
onCheckedChange={(checked) => setIncludeIds(checked === true)}
id="includeMetadata"
checked={includeMetadata}
onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
/>
<Label htmlFor="includeIds" className="cursor-pointer">
Include database IDs
<Label htmlFor="includeMetadata" className="cursor-pointer">
Include metadata
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
</p>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{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" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery
key is the master encryption key for all repositories. Keep this export secure and never
share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeTimestamps"
checked={includeTimestamps}
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includeTimestamps" className="cursor-pointer">
Include timestamps
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.
Include the hashed admin password for seamless migration. The password is already securely
hashed (argon2).
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeRuntimeState"
checked={includeRuntimeState}
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
/>
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
Include runtime state
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include current status, health checks, and last backup information. Usually not needed for migration.
</p>
{hasSecrets && (
<>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{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" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
</>
)}
{isFullExport && (
<>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).
</p>
</>
)}
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="export-password">Your Password</Label>
<Input

View file

@ -1,5 +1,4 @@
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button";
@ -126,7 +125,6 @@ export const ScheduleSummary = (props: Props) => {
<Pencil className="h-4 w-4 mr-2" />
<span className="sm:inline">Edit schedule</span>
</Button>
<ExportDialog entityType="backup-schedules" id={schedule.id} size="sm" triggerLabel="Export config" />
<Button
variant="outline"
size="sm"

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { Link } from "react-router";
import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/client/components/empty-state";
@ -120,11 +119,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
</CardContent>
</Card>
</Link>
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center justify-center gap-2 h-full w-full">
<ExportDialog entityType="backup-schedules" variant="card" />
</CardContent>
</Card>
</div>
</div>
);

View file

@ -24,8 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/notification-details";
import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, TestTube2, Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { Bell, TestTube2 } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
@ -143,7 +142,6 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
<TestTube2 className="h-4 w-4 mr-2" />
Test
</Button>
<ExportDialog entityType="notification-destinations" id={data.id} name={data.name} triggerLabel="Export config" />
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="destructive"

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Bell, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react";
import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state";
@ -123,13 +122,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<div className="flex gap-2">
<ExportDialog entityType="notification-destinations" />
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
</div>
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Database, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen";
@ -120,13 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<div className="flex gap-2">
<ExportDialog entityType="repositories" />
<Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</div>
<Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">

View file

@ -25,8 +25,7 @@ import { cn } from "~/client/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2, Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { Loader2 } from "lucide-react";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
@ -158,7 +157,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
"Run Doctor"
)}
</Button>
<ExportDialog entityType="repositories" id={data.id} name={data.name} triggerLabel="Export config" />
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
Delete
</Button>

View file

@ -144,9 +144,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<CardDescription className="mt-1.5">Your account details</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex justify-end">
<ExportDialog entityType="full" triggerLabel="Export All Config (JSON)" />
</div>
<div className="space-y-2">
<Label>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
@ -266,6 +263,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 triggerLabel="Export Configuration" />
</CardContent>
</Card>
);
}

View file

@ -25,8 +25,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types";
import { Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import {
deleteVolumeMutation,
getVolumeOptions,
@ -162,7 +160,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
>
Unmount
</Button>
<ExportDialog entityType="volumes" id={volume.id} name={volume.name} triggerLabel="Export config" />
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete
</Button>

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { HardDrive, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react";
import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state";
@ -130,13 +129,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<div className="flex gap-2">
<ExportDialog entityType="volumes" />
<Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
</div>
<Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">

View file

@ -1,13 +1,8 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import type { Context } from "hono";
import { deleteCookie, getCookie } from "hono/cookie";
import {
backupSchedulesTable,
backupScheduleNotificationsTable,
usersTable,
} from "../../db/schema";
import { backupSchedulesTable, backupScheduleNotificationsTable, usersTable } from "../../db/schema";
import { db } from "../../db/db";
import { logger } from "../../utils/logger";
import { RESTIC_PASS_FILE } from "../../core/constants";
@ -19,17 +14,9 @@ import { notificationsService } from "../notifications/notifications.service";
import { backupsService } from "../backups/backups.service";
import {
fullExportBodySchema,
entityExportBodySchema,
backupScheduleExportBodySchema,
fullExportDto,
volumesExportDto,
repositoriesExportDto,
notificationsExportDto,
backupSchedulesExportDto,
type SecretsMode,
type FullExportBody,
type EntityExportBody,
type BackupScheduleExportBody,
} from "./config-export.dto";
const COOKIE_NAME = "session_id";
@ -41,56 +28,39 @@ const COOKIE_OPTIONS = {
};
type ExportParams = {
includeIds: boolean;
includeTimestamps: boolean;
includeMetadata: boolean;
secretsMode: SecretsMode;
excludeKeys: string[];
};
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
// Keys to exclude when metadata is not included
const METADATA_KEYS = {
ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"],
timestamps: ["createdAt", "updatedAt", "lastBackupAt", "nextBackupAt", "lastHealthCheck", "lastChecked"],
runtimeState: ["status", "lastError", "lastBackupStatus", "lastBackupError", "hasDownloadedResticPassword"],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
/** Filter out metadata keys from an object when includeMetadata is false */
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of keys) {
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, includeRuntimeState: boolean): string[] {
const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"];
const timestampKeys = ["createdAt", "updatedAt"];
// Runtime state fields (status, health checks, last backup info, etc.)
const runtimeStateKeys = [
// Volume state
"status", "lastError", "lastHealthCheck",
// Repository state
"lastChecked",
// Backup schedule state
"lastBackupAt", "lastBackupStatus", "lastBackupError", "nextBackupAt",
];
// Redundant fields that are already present inside the config object
// (e.g., type is duplicated as config.backend or config.type)
const redundantKeys = ["type"];
return [
...redundantKeys,
...(includeRuntimeState ? [] : runtimeStateKeys),
...(includeIds ? [] : idKeys),
...(includeTimestamps ? [] : timestampKeys),
];
}
/** Parse export params from request body */
function parseExportParamsFromBody(body: {
includeIds?: boolean;
includeTimestamps?: boolean;
includeRuntimeState?: boolean;
includeMetadata?: boolean;
secretsMode?: SecretsMode;
}): ExportParams {
const includeIds = body.includeIds !== false;
const includeTimestamps = body.includeTimestamps !== false;
const includeRuntimeState = body.includeRuntimeState === true;
const includeMetadata = body.includeMetadata === true;
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
return { includeMetadata, secretsMode };
}
/**
@ -167,7 +137,7 @@ async function exportEntity(
entity: Record<string, unknown>,
params: ExportParams
): Promise<Record<string, unknown>> {
const cleaned = omitKeys(entity, params.excludeKeys);
const cleaned = filterMetadataOut(entity, params.includeMetadata);
return processSecrets(cleaned, params.secretsMode);
}
@ -181,8 +151,8 @@ async function exportEntities<T extends Record<string, unknown>>(
/** Transform backup schedules with resolved names and notifications */
function transformBackupSchedules(
schedules: typeof backupSchedulesTable.$inferSelect[],
scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[],
schedules: (typeof backupSchedulesTable.$inferSelect)[],
scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[],
volumeMap: Map<number, string>,
repoMap: Map<string, string>,
notificationMap: Map<number, string>,
@ -192,15 +162,12 @@ function transformBackupSchedules(
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => ({
...(params.includeIds ? { destinationId: sn.destinationId } : {}),
...filterMetadataOut(sn as unknown as Record<string, unknown>, params.includeMetadata),
name: notificationMap.get(sn.destinationId) ?? null,
notifyOnStart: sn.notifyOnStart,
notifyOnSuccess: sn.notifyOnSuccess,
notifyOnFailure: sn.notifyOnFailure,
}));
return {
...omitKeys(schedule as Record<string, unknown>, params.excludeKeys),
...filterMetadataOut(schedule as Record<string, unknown>, params.includeMetadata),
volume: volumeMap.get(schedule.volumeId) ?? null,
repository: repoMap.get(schedule.repositoryId) ?? null,
notifications: assignments,
@ -208,8 +175,11 @@ function transformBackupSchedules(
});
}
export const configExportController = new Hono()
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
export const configExportController = new Hono().post(
"/export",
fullExportDto,
validator("json", fullExportBodySchema),
async (c) => {
try {
const body = c.req.valid("json") as FullExportBody;
@ -224,21 +194,27 @@ export const configExportController = new Hono()
const includePasswordHash = body.includePasswordHash === true;
// Use services to fetch data
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
volumeService.listVolumes(),
repositoriesService.listRepositories(),
backupsService.listSchedules(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
db.select().from(usersTable).limit(1),
]);
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, users] =
await Promise.all([
volumeService.listVolumes(),
repositoriesService.listRepositories(),
backupsService.listSchedules(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
db.select().from(usersTable),
]);
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
const backupSchedules = transformBackupSchedules(
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params
backupSchedulesRaw,
scheduleNotifications,
volumeMap,
repoMap,
notificationMap,
params
);
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
@ -257,194 +233,27 @@ export const configExportController = new Hono()
}
}
// Users need special handling for passwordHash (controlled by separate flag)
const exportUsers = (await exportEntities(users, params)).map((user) => {
if (!includePasswordHash) {
delete user.passwordHash;
}
return user;
});
return c.json({
version: 1,
exportedAt: new Date().toISOString(),
...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}),
...(recoveryKey ? { recoveryKey } : {}),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
admin: admin ? {
username: admin.username,
...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}),
...(recoveryKey ? { recoveryKey } : {}),
} : null,
users: exportUsers,
});
} catch (err) {
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
}
})
.post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let volumes;
// Prefer name over id since volumeService.getVolume expects name (slug)
if (body.name) {
try {
const result = await volumeService.getVolume(body.name);
volumes = [result.volume];
} catch {
return c.json({ error: `Volume '${body.name}' not found` }, 404);
}
} else if (body.id !== undefined) {
// If only ID provided, find volume by numeric ID from list
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
if (Number.isNaN(id)) {
return c.json({ error: "Invalid volume ID" }, 400);
}
const allVolumes = await volumeService.listVolumes();
const volume = allVolumes.find((v) => v.id === id);
if (!volume) {
return c.json({ error: `Volume with ID '${body.id}' not found` }, 404);
}
volumes = [volume];
} else {
volumes = await volumeService.listVolumes();
}
return c.json({ volumes: await exportEntities(volumes, params) });
} catch (err) {
logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let repositories;
// Prefer name over id since repositoriesService.getRepository expects name (slug)
if (body.name) {
try {
const result = await repositoriesService.getRepository(body.name);
repositories = [result.repository];
} catch {
return c.json({ error: `Repository '${body.name}' not found` }, 404);
}
} else if (body.id !== undefined) {
// If only ID provided, find repository by ID from list
// Repository IDs are strings (UUIDs), not numeric
const allRepositories = await repositoriesService.listRepositories();
const repository = allRepositories.find((r) => r.id === String(body.id));
if (!repository) {
return c.json({ error: `Repository with ID '${body.id}' not found` }, 404);
}
repositories = [repository];
} else {
repositories = await repositoriesService.listRepositories();
}
return c.json({ repositories: await exportEntities(repositories, params) });
} catch (err) {
logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let notifications;
if (body.id !== undefined) {
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
if (Number.isNaN(id)) {
return c.json({ error: "Invalid notification destination ID" }, 400);
}
try {
const destination = await notificationsService.getDestination(id);
notifications = [destination];
} catch {
return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404);
}
} else if (body.name) {
// notificationsService doesn't have getByName, so we list and filter
const allDestinations = await notificationsService.listDestinations();
const destination = allDestinations.find((d) => d.name === body.name);
if (!destination) {
return c.json({ error: `Notification destination '${body.name}' not found` }, 404);
}
notifications = [destination];
} else {
notifications = await notificationsService.listDestinations();
}
return c.json({ notificationDestinations: await exportEntities(notifications, params) });
} catch (err) {
logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => {
try {
const body = c.req.valid("json") as BackupScheduleExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
// Get all related data for name resolution
const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([
volumeService.listVolumes(),
repositoriesService.listRepositories(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
]);
let schedules;
if (body.id !== undefined) {
try {
const schedule = await backupsService.getSchedule(body.id);
schedules = [schedule];
} catch {
return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404);
}
} else {
schedules = await backupsService.listSchedules();
}
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
const backupSchedules = transformBackupSchedules(
schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params
);
return c.json({ backupSchedules });
} catch (err) {
logger.error(`Backup schedules export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export backup schedules: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
});
}
);

View file

@ -3,47 +3,20 @@ import { describeRoute, resolver } from "hono-openapi";
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
const baseExportBodySchema = type({
/** Include database IDs in export (default: true) */
"includeIds?": "boolean",
/** Include createdAt/updatedAt timestamps (default: true) */
"includeTimestamps?": "boolean",
/** Include runtime state like status, health checks (default: false) */
"includeRuntimeState?": "boolean",
export const fullExportBodySchema = type({
/** Include metadata (IDs, timestamps, runtime state) in export (default: false) */
"includeMetadata?": "boolean",
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
"secretsMode?": secretsModeSchema,
/** Password required for authentication */
password: "string",
/** Include the recovery key (requires password) */
"includeRecoveryKey?": "boolean",
/** Include the admin password hash */
"includePasswordHash?": "boolean",
});
export const fullExportBodySchema = baseExportBodySchema.and(
type({
/** Include the recovery key (requires password) */
"includeRecoveryKey?": "boolean",
/** Include the admin password hash */
"includePasswordHash?": "boolean",
})
);
export const entityExportBodySchema = baseExportBodySchema.and(
type({
/** Filter by ID */
"id?": "string | number",
/** Filter by name */
"name?": "string",
})
);
export const backupScheduleExportBodySchema = baseExportBodySchema.and(
type({
/** Filter by ID */
"id?": "number",
})
);
export type FullExportBody = typeof fullExportBodySchema.infer;
export type EntityExportBody = typeof entityExportBodySchema.infer;
export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer;
export type SecretsMode = typeof secretsModeSchema.infer;
const exportResponseSchema = type({
@ -60,22 +33,6 @@ const exportResponseSchema = type({
}).or("null"),
});
const volumesExportResponseSchema = type({
volumes: "unknown[]",
});
const repositoriesExportResponseSchema = type({
repositories: "unknown[]",
});
const notificationsExportResponseSchema = type({
notificationDestinations: "unknown[]",
});
const backupSchedulesExportResponseSchema = type({
backupSchedules: "unknown[]",
});
const errorResponseSchema = type({
error: "string",
});
@ -111,195 +68,3 @@ export const fullExportDto = describeRoute({
},
},
});
export const volumesExportDto = describeRoute({
description: "Export volumes configuration",
operationId: "exportVolumes",
tags: ["Config Export"],
responses: {
200: {
description: "Volumes configuration export",
content: {
"application/json": {
schema: resolver(volumesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for export",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Volume not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const repositoriesExportDto = describeRoute({
description: "Export repositories configuration",
operationId: "exportRepositories",
tags: ["Config Export"],
responses: {
200: {
description: "Repositories configuration export",
content: {
"application/json": {
schema: resolver(repositoriesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for sensitive export options",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Repository not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const notificationsExportDto = describeRoute({
description: "Export notification destinations configuration",
operationId: "exportNotificationDestinations",
tags: ["Config Export"],
responses: {
200: {
description: "Notification destinations configuration export",
content: {
"application/json": {
schema: resolver(notificationsExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for sensitive export options",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Notification destination not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const backupSchedulesExportDto = describeRoute({
description: "Export backup schedules configuration",
operationId: "exportBackupSchedules",
tags: ["Config Export"],
responses: {
200: {
description: "Backup schedules configuration export",
content: {
"application/json": {
schema: resolver(backupSchedulesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for export",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Backup schedule not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});