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 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 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.
|
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 { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
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 { 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, 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 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
|
* Register a new user
|
||||||
|
|
@ -893,3 +893,88 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
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 type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
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> & {
|
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 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 { Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "~/client/components/ui/button";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,28 +30,8 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/client/components/ui/select";
|
} 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 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 {
|
function downloadAsJson(data: unknown, filename: string): void {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
@ -56,89 +44,51 @@ function downloadAsJson(data: unknown, filename: string): void {
|
||||||
URL.revokeObjectURL(url);
|
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";
|
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
|
||||||
|
|
||||||
type ExportConfig = {
|
type ExportConfig = {
|
||||||
endpoint: string;
|
|
||||||
label: string;
|
label: string;
|
||||||
labelPlural: string;
|
labelPlural: string;
|
||||||
getFilename: (options: ExportOptions) => string;
|
getFilename: (id?: string | number, name?: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
|
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
|
||||||
volumes: {
|
volumes: {
|
||||||
endpoint: "/api/v1/config/export/volumes",
|
|
||||||
label: "Volume",
|
label: "Volume",
|
||||||
labelPlural: "Volumes",
|
labelPlural: "Volumes",
|
||||||
getFilename: (opts) => {
|
getFilename: (id, name) => {
|
||||||
const identifier = opts.id ?? opts.name;
|
const identifier = id ?? name;
|
||||||
return identifier ? `volume-${identifier}-config` : "volumes-config";
|
return identifier ? `volume-${identifier}-config` : "volumes-config";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
repositories: {
|
repositories: {
|
||||||
endpoint: "/api/v1/config/export/repositories",
|
|
||||||
label: "Repository",
|
label: "Repository",
|
||||||
labelPlural: "Repositories",
|
labelPlural: "Repositories",
|
||||||
getFilename: (opts) => {
|
getFilename: (id, name) => {
|
||||||
const identifier = opts.id ?? opts.name;
|
const identifier = id ?? name;
|
||||||
return identifier ? `repository-${identifier}-config` : "repositories-config";
|
return identifier ? `repository-${identifier}-config` : "repositories-config";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"notification-destinations": {
|
"notification-destinations": {
|
||||||
endpoint: "/api/v1/config/export/notification-destinations",
|
|
||||||
label: "Notification Destination",
|
label: "Notification Destination",
|
||||||
labelPlural: "Notification Destinations",
|
labelPlural: "Notification Destinations",
|
||||||
getFilename: (opts) => {
|
getFilename: (id, name) => {
|
||||||
const identifier = opts.id ?? opts.name;
|
const identifier = id ?? name;
|
||||||
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
|
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"backup-schedules": {
|
"backup-schedules": {
|
||||||
endpoint: "/api/v1/config/export/backup-schedules",
|
|
||||||
label: "Backup Schedule",
|
label: "Backup Schedule",
|
||||||
labelPlural: "Backup Schedules",
|
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: {
|
full: {
|
||||||
endpoint: "/api/v1/config/export",
|
|
||||||
label: "Full Config",
|
label: "Full Config",
|
||||||
labelPlural: "Full Config",
|
labelPlural: "Full Config",
|
||||||
getFilename: () => "zerobyte-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 = {
|
type BaseExportDialogProps = {
|
||||||
entityType: ExportEntityType;
|
entityType: ExportEntityType;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -181,10 +131,7 @@ export function ExportDialog({
|
||||||
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
|
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
|
||||||
const [includePasswordHash, setIncludePasswordHash] = useState(false);
|
const [includePasswordHash, setIncludePasswordHash] = useState(false);
|
||||||
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
|
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
const [showPasswordStep, setShowPasswordStep] = useState(false);
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
|
||||||
|
|
||||||
const config = exportConfigs[entityType];
|
const config = exportConfigs[entityType];
|
||||||
const isSingleItem = !!(name || id);
|
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
|
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
||||||
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
|
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
|
||||||
const entityLabel = isSingleItem ? config.label : config.labelPlural;
|
const entityLabel = isSingleItem ? config.label : config.labelPlural;
|
||||||
const requiresPassword = includeRecoveryKey || secretsMode === "cleartext";
|
const filename = config.getFilename(id, name);
|
||||||
|
|
||||||
const performExport = async () => {
|
const handleExportSuccess = (data: unknown) => {
|
||||||
setIsExporting(true);
|
downloadAsJson(data, filename);
|
||||||
try {
|
toast.success(`${entityLabel} exported successfully`);
|
||||||
await exportConfig(entityType, {
|
setOpen(false);
|
||||||
includeIds,
|
setPassword("");
|
||||||
includeTimestamps,
|
};
|
||||||
includeRuntimeState,
|
|
||||||
includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined,
|
const handleExportError = (error: unknown) => {
|
||||||
includePasswordHash: isFullExport ? includePasswordHash : undefined,
|
const message = error && typeof error === "object" && "error" in error
|
||||||
secretsMode: hasSecrets ? secretsMode : undefined,
|
? (error as { error: string }).error
|
||||||
name,
|
: "Unknown error";
|
||||||
id,
|
toast.error("Export failed", {
|
||||||
});
|
description: message,
|
||||||
toast.success(`${entityLabel} exported successfully`);
|
});
|
||||||
setOpen(false);
|
};
|
||||||
setShowPasswordStep(false);
|
|
||||||
setPassword("");
|
const fullExport = useMutation({
|
||||||
} catch (err) {
|
...exportFullConfigMutation(),
|
||||||
toast.error("Export failed", {
|
onSuccess: handleExportSuccess,
|
||||||
description: err instanceof Error ? err.message : String(err),
|
onError: handleExportError,
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
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 = () => {
|
const exportMutation = getMutation();
|
||||||
if (requiresPassword) {
|
|
||||||
setShowPasswordStep(true);
|
|
||||||
} else {
|
|
||||||
performExport();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
const handleExport = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!password) {
|
if (!password) {
|
||||||
toast.error("Password is required");
|
toast.error("Password is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsVerifying(true);
|
const baseBody = {
|
||||||
try {
|
password,
|
||||||
const isValid = await verifyPassword(password);
|
includeIds,
|
||||||
if (!isValid) {
|
includeTimestamps,
|
||||||
toast.error("Incorrect password");
|
includeRuntimeState,
|
||||||
return;
|
secretsMode: hasSecrets ? secretsMode : undefined,
|
||||||
}
|
};
|
||||||
// Password verified, proceed with export
|
|
||||||
await performExport();
|
switch (entityType) {
|
||||||
} catch (err) {
|
case "full":
|
||||||
toast.error("Verification failed", {
|
fullExport.mutate({
|
||||||
description: err instanceof Error ? err.message : "Unable to verify password. Please check your connection and try again.",
|
body: {
|
||||||
});
|
...baseBody,
|
||||||
} finally {
|
includeRecoveryKey,
|
||||||
setIsVerifying(false);
|
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) => {
|
const handleDialogChange = (isOpen: boolean) => {
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setShowPasswordStep(false);
|
|
||||||
setPassword("");
|
setPassword("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -283,172 +296,146 @@ export function ExportDialog({
|
||||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||||
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
|
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{showPasswordStep ? (
|
<form onSubmit={handleExport}>
|
||||||
<form onSubmit={handlePasswordSubmit}>
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>Export {entityLabel}</DialogTitle>
|
||||||
<DialogTitle>Confirm Export</DialogTitle>
|
<DialogDescription>
|
||||||
<DialogDescription>
|
{isFullExport
|
||||||
For security reasons, please enter your password to export sensitive information
|
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
|
||||||
</DialogDescription>
|
: isSingleItem
|
||||||
</DialogHeader>
|
? `Export the configuration for this ${config.label.toLowerCase()}.`
|
||||||
<div className="space-y-4 py-4">
|
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
|
||||||
<div className="space-y-2">
|
</DialogDescription>
|
||||||
<Label htmlFor="export-password">Your Password</Label>
|
</DialogHeader>
|
||||||
<Input
|
|
||||||
id="export-password"
|
<div className="space-y-4 py-4">
|
||||||
type="password"
|
<div className="flex items-center space-x-3">
|
||||||
value={password}
|
<Checkbox
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
id="includeIds"
|
||||||
placeholder="Enter your password"
|
checked={includeIds}
|
||||||
required
|
onCheckedChange={(checked) => setIncludeIds(checked === true)}
|
||||||
autoFocus
|
/>
|
||||||
/>
|
<Label htmlFor="includeIds" className="cursor-pointer">
|
||||||
</div>
|
Include database IDs
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<p className="text-xs text-muted-foreground ml-7">
|
||||||
<Button
|
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
|
||||||
type="button"
|
</p>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="flex items-center space-x-3">
|
<Checkbox
|
||||||
<Checkbox
|
id="includeTimestamps"
|
||||||
id="includeIds"
|
checked={includeTimestamps}
|
||||||
checked={includeIds}
|
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
|
||||||
onCheckedChange={(checked) => setIncludeIds(checked === true)}
|
/>
|
||||||
/>
|
<Label htmlFor="includeTimestamps" className="cursor-pointer">
|
||||||
<Label htmlFor="includeIds" className="cursor-pointer">
|
Include timestamps
|
||||||
Include database IDs
|
</Label>
|
||||||
</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>
|
</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>
|
<div className="flex items-center space-x-3">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Checkbox
|
||||||
Cancel
|
id="includeRuntimeState"
|
||||||
</Button>
|
checked={includeRuntimeState}
|
||||||
<Button onClick={handleExport} loading={isExporting}>
|
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
|
||||||
<Download className="h-4 w-4 mr-2" />
|
/>
|
||||||
Export
|
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
|
||||||
</Button>
|
Include runtime state
|
||||||
</DialogFooter>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,12 @@ import {
|
||||||
logoutDto,
|
logoutDto,
|
||||||
registerBodySchema,
|
registerBodySchema,
|
||||||
registerDto,
|
registerDto,
|
||||||
verifyPasswordBodySchema,
|
|
||||||
verifyPasswordDto,
|
|
||||||
type ChangePasswordDto,
|
type ChangePasswordDto,
|
||||||
type GetMeDto,
|
type GetMeDto,
|
||||||
type GetStatusDto,
|
type GetStatusDto,
|
||||||
type LoginDto,
|
type LoginDto,
|
||||||
type LogoutDto,
|
type LogoutDto,
|
||||||
type RegisterDto,
|
type RegisterDto,
|
||||||
type VerifyPasswordDto,
|
|
||||||
} from "./auth.dto";
|
} from "./auth.dto";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
|
@ -141,27 +138,4 @@ export const authController = new Hono()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
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 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 LoginBody = typeof loginBodySchema.infer;
|
||||||
export type RegisterBody = typeof registerBodySchema.infer;
|
export type RegisterBody = typeof registerBodySchema.infer;
|
||||||
export type ChangePasswordBody = typeof changePasswordBodySchema.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 { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { eq } from "drizzle-orm";
|
import { deleteCookie, getCookie } from "hono/cookie";
|
||||||
import {
|
import {
|
||||||
backupSchedulesTable,
|
backupSchedulesTable,
|
||||||
notificationDestinationsTable,
|
|
||||||
repositoriesTable,
|
|
||||||
backupScheduleNotificationsTable,
|
backupScheduleNotificationsTable,
|
||||||
usersTable,
|
usersTable,
|
||||||
volumesTable,
|
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
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";
|
||||||
|
|
||||||
// ============================================================================
|
const COOKIE_NAME = "session_id";
|
||||||
// Types
|
const COOKIE_OPTIONS = {
|
||||||
// ============================================================================
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
type ExportParams = {
|
type ExportParams = {
|
||||||
includeIds: boolean;
|
includeIds: boolean;
|
||||||
|
|
@ -27,14 +47,6 @@ type ExportParams = {
|
||||||
excludeKeys: string[];
|
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> {
|
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
|
||||||
const result = { ...obj };
|
const result = { ...obj };
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|
@ -66,25 +78,46 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, include
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse common export query parameters from request */
|
/** Parse export params from request body */
|
||||||
function parseExportParams(c: Context): ExportParams {
|
function parseExportParamsFromBody(body: {
|
||||||
const includeIds = c.req.query("includeIds") !== "false";
|
includeIds?: boolean;
|
||||||
const includeTimestamps = c.req.query("includeTimestamps") !== "false";
|
includeTimestamps?: boolean;
|
||||||
const includeRuntimeState = c.req.query("includeRuntimeState") === "true";
|
includeRuntimeState?: boolean;
|
||||||
const secretsModeRaw = c.req.query("secretsMode");
|
secretsMode?: SecretsMode;
|
||||||
const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"];
|
}): ExportParams {
|
||||||
const secretsMode: SecretsMode = secretsModeRaw
|
const includeIds = body.includeIds !== false;
|
||||||
? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode)
|
const includeTimestamps = body.includeTimestamps !== false;
|
||||||
? (secretsModeRaw as SecretsMode)
|
const includeRuntimeState = body.includeRuntimeState === true;
|
||||||
: (() => { throw new Error("Invalid secretsMode parameter"); })())
|
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
|
||||||
: "exclude";
|
|
||||||
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
|
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
|
||||||
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
|
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get filter options from request query params */
|
/**
|
||||||
function getFilterOptions(c: Context): FilterOptions {
|
* Verify password for export operation.
|
||||||
return { id: c.req.query("id"), name: c.req.query("name") };
|
* 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)));
|
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 */
|
/** Transform backup schedules with resolved names and notifications */
|
||||||
function transformBackupSchedules(
|
function transformBackupSchedules(
|
||||||
schedules: typeof backupSchedulesTable.$inferSelect[],
|
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()
|
export const configExportController = new Hono()
|
||||||
.get("/export", async (c) => {
|
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
||||||
try {
|
try {
|
||||||
const params = parseExportParams(c);
|
const body = c.req.valid("json") as FullExportBody;
|
||||||
const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true";
|
|
||||||
const includePasswordHash = c.req.query("includePasswordHash") === "true";
|
|
||||||
|
|
||||||
|
// 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([
|
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
|
||||||
db.select().from(volumesTable),
|
volumeService.listVolumes(),
|
||||||
db.select().from(repositoriesTable),
|
repositoriesService.listRepositories(),
|
||||||
db.select().from(backupSchedulesTable),
|
backupsService.listSchedules(),
|
||||||
db.select().from(notificationDestinationsTable),
|
notificationsService.listDestinations(),
|
||||||
db.select().from(backupScheduleNotificationsTable),
|
db.select().from(backupScheduleNotificationsTable),
|
||||||
db.select().from(usersTable).limit(1),
|
db.select().from(usersTable).limit(1),
|
||||||
]);
|
]);
|
||||||
|
|
@ -281,9 +241,6 @@ export const configExportController = new Hono()
|
||||||
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
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([
|
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
|
||||||
exportEntities(volumes, params),
|
exportEntities(volumes, params),
|
||||||
exportEntities(repositories, 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);
|
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 {
|
try {
|
||||||
const params = parseExportParams(c);
|
const body = c.req.valid("json") as EntityExportBody;
|
||||||
const result = await fetchVolumes(getFilterOptions(c));
|
|
||||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
// Verify password - required for all exports
|
||||||
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
const verification = await verifyExportPassword(c, body.password);
|
||||||
return c.json({ volumes: await exportEntities(result.data, params) });
|
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) {
|
} catch (err) {
|
||||||
logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(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);
|
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 {
|
try {
|
||||||
const params = parseExportParams(c);
|
const body = c.req.valid("json") as EntityExportBody;
|
||||||
const result = await fetchRepositories(getFilterOptions(c));
|
|
||||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
// Verify password - required for all exports
|
||||||
return c.json({ repositories: await exportEntities(result.data, params) });
|
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) {
|
} catch (err) {
|
||||||
logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(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);
|
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 {
|
try {
|
||||||
const params = parseExportParams(c);
|
const body = c.req.valid("json") as EntityExportBody;
|
||||||
const result = await fetchNotifications(getFilterOptions(c));
|
|
||||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
// Verify password - required for all exports
|
||||||
return c.json({ notificationDestinations: await exportEntities(result.data, params) });
|
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) {
|
} catch (err) {
|
||||||
logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(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);
|
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 {
|
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([
|
const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([
|
||||||
db.select().from(volumesTable),
|
volumeService.listVolumes(),
|
||||||
db.select().from(repositoriesTable),
|
repositoriesService.listRepositories(),
|
||||||
db.select().from(notificationDestinationsTable),
|
notificationsService.listDestinations(),
|
||||||
db.select().from(backupScheduleNotificationsTable),
|
db.select().from(backupScheduleNotificationsTable),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await fetchBackupSchedules({ id: c.req.query("id") });
|
let schedules;
|
||||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
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 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 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 notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
|
||||||
|
|
||||||
const backupSchedules = transformBackupSchedules(
|
const backupSchedules = transformBackupSchedules(
|
||||||
result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json({ backupSchedules });
|
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