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)
This commit is contained in:
parent
2dfa15a83a
commit
9f1a7694a3
9 changed files with 1189 additions and 486 deletions
|
|
@ -215,7 +215,7 @@ To export, click the "Export" button on any list page or detail page. A dialog w
|
|||
- **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
|
||||
|
||||
When exporting sensitive data (recovery key or decrypted secrets), you'll be prompted to re-enter your password as an additional security confirmation. This is a UI-level safeguard to prevent accidental exports of sensitive information.
|
||||
All exports require 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, 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, 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, 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';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -893,3 +893,88 @@ 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, ExportFullConfigError, Options<ExportFullConfigData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportFullConfig({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, 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, 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';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -567,3 +567,73 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
|
|||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export full configuration including all volumes, repositories, backup schedules, and notifications
|
||||
*/
|
||||
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ExportFullConfigResponses, ExportFullConfigErrors, ThrowOnError>({
|
||||
url: '/api/v1/config/export',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2615,3 +2615,263 @@ export type DownloadResticPasswordResponses = {
|
|||
};
|
||||
|
||||
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||
|
||||
export type ExportFullConfigData = {
|
||||
body?: {
|
||||
password: string;
|
||||
includeIds?: boolean;
|
||||
includePasswordHash?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export';
|
||||
};
|
||||
|
||||
export type ExportFullConfigErrors = {
|
||||
/**
|
||||
* Password required for sensitive export options
|
||||
*/
|
||||
401: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Export failed
|
||||
*/
|
||||
500: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportFullConfigError = ExportFullConfigErrors[keyof ExportFullConfigErrors];
|
||||
|
||||
export type ExportFullConfigResponses = {
|
||||
/**
|
||||
* Full configuration export
|
||||
*/
|
||||
200: {
|
||||
admin?: {
|
||||
username: string;
|
||||
passwordHash?: string;
|
||||
recoveryKey?: string;
|
||||
} | null;
|
||||
backupSchedules?: Array<unknown>;
|
||||
exportedAt?: string;
|
||||
notificationDestinations?: Array<unknown>;
|
||||
repositories?: Array<unknown>;
|
||||
version?: number;
|
||||
volumes?: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
/**
|
||||
* 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];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
exportBackupSchedulesMutation,
|
||||
exportFullConfigMutation,
|
||||
exportNotificationDestinationsMutation,
|
||||
exportRepositoriesMutation,
|
||||
exportVolumesMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -22,28 +30,8 @@ import {
|
|||
SelectValue,
|
||||
} from "~/client/components/ui/select";
|
||||
|
||||
async function verifyPassword(password: string): Promise<boolean> {
|
||||
const response = await fetch("/api/v1/auth/verify-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
|
||||
export type ExportOptions = {
|
||||
includeIds?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
includePasswordHash?: boolean;
|
||||
secretsMode?: SecretsMode;
|
||||
name?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
function downloadAsJson(data: unknown, filename: string): void {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -56,89 +44,51 @@ function downloadAsJson(data: unknown, filename: string): void {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeIds === false) params.set("includeIds", "false");
|
||||
if (options.includeTimestamps === false) params.set("includeTimestamps", "false");
|
||||
if (options.includeRuntimeState === true) params.set("includeRuntimeState", "true");
|
||||
if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true");
|
||||
if (options.includePasswordHash === true) params.set("includePasswordHash", "true");
|
||||
if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode);
|
||||
if (options.id !== undefined) params.set("id", String(options.id));
|
||||
if (options.name) params.set("name", options.name);
|
||||
|
||||
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => res.statusText);
|
||||
throw new Error(errorText || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
downloadAsJson(data, filename);
|
||||
}
|
||||
|
||||
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
|
||||
|
||||
type ExportConfig = {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
labelPlural: string;
|
||||
getFilename: (options: ExportOptions) => string;
|
||||
getFilename: (id?: string | number, name?: string) => string;
|
||||
};
|
||||
|
||||
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
|
||||
volumes: {
|
||||
endpoint: "/api/v1/config/export/volumes",
|
||||
label: "Volume",
|
||||
labelPlural: "Volumes",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `volume-${identifier}-config` : "volumes-config";
|
||||
},
|
||||
},
|
||||
repositories: {
|
||||
endpoint: "/api/v1/config/export/repositories",
|
||||
label: "Repository",
|
||||
labelPlural: "Repositories",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `repository-${identifier}-config` : "repositories-config";
|
||||
},
|
||||
},
|
||||
"notification-destinations": {
|
||||
endpoint: "/api/v1/config/export/notification-destinations",
|
||||
label: "Notification Destination",
|
||||
labelPlural: "Notification Destinations",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
|
||||
},
|
||||
},
|
||||
"backup-schedules": {
|
||||
endpoint: "/api/v1/config/export/backup-schedules",
|
||||
label: "Backup Schedule",
|
||||
labelPlural: "Backup Schedules",
|
||||
getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"),
|
||||
getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"),
|
||||
},
|
||||
full: {
|
||||
endpoint: "/api/v1/config/export",
|
||||
label: "Full Config",
|
||||
labelPlural: "Full Config",
|
||||
getFilename: () => "zerobyte-full-config",
|
||||
},
|
||||
};
|
||||
|
||||
export async function exportConfig(
|
||||
entityType: ExportEntityType,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const config = exportConfigs[entityType];
|
||||
const filename = config.getFilename(options);
|
||||
await exportFromApi(config.endpoint, filename, options);
|
||||
}
|
||||
|
||||
type BaseExportDialogProps = {
|
||||
entityType: ExportEntityType;
|
||||
name?: string;
|
||||
|
|
@ -181,10 +131,7 @@ export function ExportDialog({
|
|||
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
|
||||
const [includePasswordHash, setIncludePasswordHash] = useState(false);
|
||||
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showPasswordStep, setShowPasswordStep] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const config = exportConfigs[entityType];
|
||||
const isSingleItem = !!(name || id);
|
||||
|
|
@ -192,71 +139,137 @@ export function ExportDialog({
|
|||
// 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 requiresPassword = includeRecoveryKey || secretsMode === "cleartext";
|
||||
const filename = config.getFilename(id, name);
|
||||
|
||||
const performExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportConfig(entityType, {
|
||||
includeIds,
|
||||
includeTimestamps,
|
||||
includeRuntimeState,
|
||||
includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined,
|
||||
includePasswordHash: isFullExport ? includePasswordHash : undefined,
|
||||
secretsMode: hasSecrets ? secretsMode : undefined,
|
||||
name,
|
||||
id,
|
||||
});
|
||||
toast.success(`${entityLabel} exported successfully`);
|
||||
setOpen(false);
|
||||
setShowPasswordStep(false);
|
||||
setPassword("");
|
||||
} catch (err) {
|
||||
toast.error("Export failed", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
const handleExportSuccess = (data: unknown) => {
|
||||
downloadAsJson(data, filename);
|
||||
toast.success(`${entityLabel} exported successfully`);
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
const handleExportError = (error: unknown) => {
|
||||
const message = error && typeof error === "object" && "error" in error
|
||||
? (error as { error: string }).error
|
||||
: "Unknown error";
|
||||
toast.error("Export failed", {
|
||||
description: message,
|
||||
});
|
||||
};
|
||||
|
||||
const fullExport = 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 handleExport = () => {
|
||||
if (requiresPassword) {
|
||||
setShowPasswordStep(true);
|
||||
} else {
|
||||
performExport();
|
||||
}
|
||||
};
|
||||
const exportMutation = getMutation();
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
const handleExport = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const isValid = await verifyPassword(password);
|
||||
if (!isValid) {
|
||||
toast.error("Incorrect password");
|
||||
return;
|
||||
}
|
||||
// Password verified, proceed with export
|
||||
await performExport();
|
||||
} catch (err) {
|
||||
toast.error("Verification failed", {
|
||||
description: err instanceof Error ? err.message : "Unable to verify password. Please check your connection and try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setShowPasswordStep(false);
|
||||
setPassword("");
|
||||
}
|
||||
};
|
||||
|
|
@ -283,172 +296,146 @@ export function ExportDialog({
|
|||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
{showPasswordStep ? (
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Export</DialogTitle>
|
||||
<DialogDescription>
|
||||
For security reasons, please enter your password to export sensitive information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<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"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleExport}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export {entityLabel}</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.`}
|
||||
</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)}
|
||||
/>
|
||||
<Label htmlFor="includeIds" className="cursor-pointer">
|
||||
Include database IDs
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordStep(false);
|
||||
setPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" loading={isVerifying || isExporting}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export {entityLabel}</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.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<Label htmlFor="includeIds" className="cursor-pointer">
|
||||
Include database IDs
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeTimestamps"
|
||||
checked={includeTimestamps}
|
||||
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeTimestamps" className="cursor-pointer">
|
||||
Include timestamps
|
||||
</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.
|
||||
</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="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeTimestamps"
|
||||
checked={includeTimestamps}
|
||||
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeTimestamps" className="cursor-pointer">
|
||||
Include timestamps
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,15 +12,12 @@ import {
|
|||
logoutDto,
|
||||
registerBodySchema,
|
||||
registerDto,
|
||||
verifyPasswordBodySchema,
|
||||
verifyPasswordDto,
|
||||
type ChangePasswordDto,
|
||||
type GetMeDto,
|
||||
type GetStatusDto,
|
||||
type LoginDto,
|
||||
type LogoutDto,
|
||||
type RegisterDto,
|
||||
type VerifyPasswordDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
|
@ -141,27 +138,4 @@ export const authController = new Hono()
|
|||
} catch (error) {
|
||||
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
})
|
||||
.post("/verify-password", verifyPasswordDto, validator("json", verifyPasswordBodySchema), async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
const isValid = await authService.verifyPassword(session.user.id, body.password);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json<VerifyPasswordDto>({ success: false, message: "Incorrect password" }, 401);
|
||||
}
|
||||
|
||||
return c.json<VerifyPasswordDto>({ success: true, message: "Password verified" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,34 +148,6 @@ export const changePasswordDto = describeRoute({
|
|||
|
||||
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
||||
|
||||
export const verifyPasswordBodySchema = type({
|
||||
password: "string>0",
|
||||
});
|
||||
|
||||
const verifyPasswordResponseSchema = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export const verifyPasswordDto = describeRoute({
|
||||
description: "Verify current user password for re-authentication",
|
||||
operationId: "verifyPassword",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Password verification result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(verifyPasswordResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer;
|
||||
|
||||
export type LoginBody = typeof loginBodySchema.infer;
|
||||
export type RegisterBody = typeof registerBodySchema.infer;
|
||||
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
||||
export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,44 @@
|
|||
import { validator } from "hono-openapi";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { deleteCookie, getCookie } from "hono/cookie";
|
||||
import {
|
||||
backupSchedulesTable,
|
||||
notificationDestinationsTable,
|
||||
repositoriesTable,
|
||||
backupScheduleNotificationsTable,
|
||||
usersTable,
|
||||
volumesTable,
|
||||
} from "../../db/schema";
|
||||
import { db } from "../../db/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { repositoriesService } from "../repositories/repositories.service";
|
||||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { backupsService } from "../backups/backups.service";
|
||||
import {
|
||||
fullExportBodySchema,
|
||||
entityExportBodySchema,
|
||||
backupScheduleExportBodySchema,
|
||||
fullExportDto,
|
||||
volumesExportDto,
|
||||
repositoriesExportDto,
|
||||
notificationsExportDto,
|
||||
backupSchedulesExportDto,
|
||||
type SecretsMode,
|
||||
type FullExportBody,
|
||||
type EntityExportBody,
|
||||
type BackupScheduleExportBody,
|
||||
} from "./config-export.dto";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
const COOKIE_NAME = "session_id";
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
type ExportParams = {
|
||||
includeIds: boolean;
|
||||
|
|
@ -27,14 +47,6 @@ type ExportParams = {
|
|||
excludeKeys: string[];
|
||||
};
|
||||
|
||||
type FilterOptions = { id?: string; name?: string };
|
||||
|
||||
type FetchResult<T> = { data: T[] } | { error: string; status: 400 | 404 };
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
|
|
@ -66,25 +78,46 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, include
|
|||
];
|
||||
}
|
||||
|
||||
/** Parse common export query parameters from request */
|
||||
function parseExportParams(c: Context): ExportParams {
|
||||
const includeIds = c.req.query("includeIds") !== "false";
|
||||
const includeTimestamps = c.req.query("includeTimestamps") !== "false";
|
||||
const includeRuntimeState = c.req.query("includeRuntimeState") === "true";
|
||||
const secretsModeRaw = c.req.query("secretsMode");
|
||||
const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"];
|
||||
const secretsMode: SecretsMode = secretsModeRaw
|
||||
? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode)
|
||||
? (secretsModeRaw as SecretsMode)
|
||||
: (() => { throw new Error("Invalid secretsMode parameter"); })())
|
||||
: "exclude";
|
||||
/** Parse export params from request body */
|
||||
function parseExportParamsFromBody(body: {
|
||||
includeIds?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
secretsMode?: SecretsMode;
|
||||
}): ExportParams {
|
||||
const includeIds = body.includeIds !== false;
|
||||
const includeTimestamps = body.includeTimestamps !== false;
|
||||
const includeRuntimeState = body.includeRuntimeState === true;
|
||||
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
|
||||
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
|
||||
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
|
||||
}
|
||||
|
||||
/** Get filter options from request query params */
|
||||
function getFilterOptions(c: Context): FilterOptions {
|
||||
return { id: c.req.query("id"), name: c.req.query("name") };
|
||||
/**
|
||||
* Verify password for export operation.
|
||||
* All exports require password verification for security.
|
||||
*/
|
||||
async function verifyExportPassword(
|
||||
c: Context,
|
||||
password: string
|
||||
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
if (!sessionId) {
|
||||
return { valid: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return { valid: false, error: "Session expired" };
|
||||
}
|
||||
|
||||
const isValid = await authService.verifyPassword(session.user.id, password);
|
||||
if (!isValid) {
|
||||
return { valid: false, error: "Incorrect password" };
|
||||
}
|
||||
|
||||
return { valid: true, userId: session.user.id };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,72 +179,6 @@ async function exportEntities<T extends Record<string, unknown>>(
|
|||
return Promise.all(entities.map((e) => exportEntity(e as Record<string, unknown>, params)));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetchers with Filtering
|
||||
// ============================================================================
|
||||
|
||||
async function fetchVolumes(filter: FilterOptions): Promise<FetchResult<typeof volumesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 };
|
||||
const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id));
|
||||
if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(volumesTable) };
|
||||
}
|
||||
|
||||
async function fetchRepositories(filter: FilterOptions): Promise<FetchResult<typeof repositoriesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
// Validate UUID format
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(filter.id)) {
|
||||
return { error: "Invalid repository ID format (expected UUID)", status: 400 };
|
||||
}
|
||||
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id));
|
||||
if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(repositoriesTable) };
|
||||
}
|
||||
|
||||
async function fetchNotifications(filter: FilterOptions): Promise<FetchResult<typeof notificationDestinationsTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 };
|
||||
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
|
||||
if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(notificationDestinationsTable) };
|
||||
}
|
||||
|
||||
async function fetchBackupSchedules(filter: { id?: string }): Promise<FetchResult<typeof backupSchedulesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 };
|
||||
const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id));
|
||||
if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(backupSchedulesTable) };
|
||||
}
|
||||
|
||||
/** Transform backup schedules with resolved names and notifications */
|
||||
function transformBackupSchedules(
|
||||
schedules: typeof backupSchedulesTable.$inferSelect[],
|
||||
|
|
@ -241,34 +208,27 @@ function transformBackupSchedules(
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Controller
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Config Export API
|
||||
*
|
||||
* Query parameters:
|
||||
* - includeIds: "true" | "false" (default: "true") - Include database IDs
|
||||
* - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt
|
||||
* - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only)
|
||||
* - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only)
|
||||
* - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets
|
||||
* - id: string (optional) - Filter by ID
|
||||
* - name: string (optional) - Filter by name (not for backups)
|
||||
*/
|
||||
export const configExportController = new Hono()
|
||||
.get("/export", async (c) => {
|
||||
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true";
|
||||
const includePasswordHash = c.req.query("includePasswordHash") === "true";
|
||||
const body = c.req.valid("json") as FullExportBody;
|
||||
|
||||
// Verify password - required for all exports
|
||||
const verification = await verifyExportPassword(c, body.password);
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 401);
|
||||
}
|
||||
|
||||
const params = parseExportParamsFromBody(body);
|
||||
const includeRecoveryKey = body.includeRecoveryKey === true;
|
||||
const includePasswordHash = body.includePasswordHash === true;
|
||||
|
||||
// Use services to fetch data
|
||||
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
|
||||
db.select().from(volumesTable),
|
||||
db.select().from(repositoriesTable),
|
||||
db.select().from(backupSchedulesTable),
|
||||
db.select().from(notificationDestinationsTable),
|
||||
volumeService.listVolumes(),
|
||||
repositoriesService.listRepositories(),
|
||||
backupsService.listSchedules(),
|
||||
notificationsService.listDestinations(),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
db.select().from(usersTable).limit(1),
|
||||
]);
|
||||
|
|
@ -281,9 +241,6 @@ export const configExportController = new Hono()
|
|||
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
);
|
||||
|
||||
// WARNING: As of now, volume exports may include sensitive data (e.g., SMB/NFS credentials) in cleartext.
|
||||
// This is a known security limitation. Handle exported configuration files with care.
|
||||
// Future PRs will implement encryption for these secrets.
|
||||
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
|
||||
exportEntities(volumes, params),
|
||||
exportEntities(repositories, params),
|
||||
|
|
@ -318,60 +275,169 @@ export const configExportController = new Hono()
|
|||
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
|
||||
}
|
||||
})
|
||||
.get("/export/volumes", async (c) => {
|
||||
.post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchVolumes(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
||||
return c.json({ volumes: await exportEntities(result.data, params) });
|
||||
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);
|
||||
}
|
||||
})
|
||||
.get("/export/repositories", async (c) => {
|
||||
.post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchRepositories(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
return c.json({ repositories: await exportEntities(result.data, params) });
|
||||
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);
|
||||
}
|
||||
})
|
||||
.get("/export/notification-destinations", async (c) => {
|
||||
.post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchNotifications(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
return c.json({ notificationDestinations: await exportEntities(result.data, params) });
|
||||
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);
|
||||
}
|
||||
})
|
||||
.get("/export/backup-schedules", async (c) => {
|
||||
.post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
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([
|
||||
db.select().from(volumesTable),
|
||||
db.select().from(repositoriesTable),
|
||||
db.select().from(notificationDestinationsTable),
|
||||
volumeService.listVolumes(),
|
||||
repositoriesService.listRepositories(),
|
||||
notificationsService.listDestinations(),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
]);
|
||||
|
||||
const result = await fetchBackupSchedules({ id: c.req.query("id") });
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
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(
|
||||
result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
);
|
||||
|
||||
return c.json({ backupSchedules });
|
||||
|
|
|
|||
289
app/server/modules/lifecycle/config-export.dto.ts
Normal file
289
app/server/modules/lifecycle/config-export.dto.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { type } from "arktype";
|
||||
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",
|
||||
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
|
||||
"secretsMode?": secretsModeSchema,
|
||||
/** Password required for authentication */
|
||||
password: "string",
|
||||
});
|
||||
|
||||
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({
|
||||
"version?": "number",
|
||||
"exportedAt?": "string",
|
||||
"volumes?": "unknown[]",
|
||||
"repositories?": "unknown[]",
|
||||
"backupSchedules?": "unknown[]",
|
||||
"notificationDestinations?": "unknown[]",
|
||||
"admin?": type({
|
||||
username: "string",
|
||||
"passwordHash?": "string",
|
||||
"recoveryKey?": "string",
|
||||
}).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",
|
||||
});
|
||||
|
||||
export const fullExportDto = describeRoute({
|
||||
description: "Export full configuration including all volumes, repositories, backup schedules, and notifications",
|
||||
operationId: "exportFullConfig",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Full configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(exportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Password required for sensitive export options",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Backup schedule not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue