diff --git a/README.md b/README.md index 93f4c129..b9732a9c 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ To export, click the "Export" button on any list page or detail page. A dialog w - **Include recovery key** (full export only) - Include the master encryption key for all repositories - **Include password hash** (full export only) - Include the hashed admin password for seamless migration -When exporting sensitive data (recovery key or decrypted secrets), you'll be prompted to re-enter your password as an additional security confirmation. This is a UI-level safeguard to prevent accidental exports of sensitive information. +All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported. Exports are downloaded as JSON files that can be used for reference or future import functionality. diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 5beda44b..4114ad91 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -893,3 +893,88 @@ export const downloadResticPasswordMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportFullConfig({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Export volumes configuration + */ +export const exportVolumesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportVolumes({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Export repositories configuration + */ +export const exportRepositoriesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportRepositories({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Export notification destinations configuration + */ +export const exportNotificationDestinationsMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportNotificationDestinations({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Export backup schedules configuration + */ +export const exportBackupSchedulesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportBackupSchedules({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 9f5afe46..ea0d832f 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, 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 = Options2 & { /** @@ -567,3 +567,73 @@ export const downloadResticPassword = (opt } }); }; + +/** + * Export full configuration including all volumes, repositories, backup schedules, and notifications + */ +export const exportFullConfig = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/config/export', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Export volumes configuration + */ +export const exportVolumes = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/config/export/volumes', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Export repositories configuration + */ +export const exportRepositories = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/config/export/repositories', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Export notification destinations configuration + */ +export const exportNotificationDestinations = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/config/export/notification-destinations', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Export backup schedules configuration + */ +export const exportBackupSchedules = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/config/export/backup-schedules', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 7e82ed3f..d025a2fe 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2615,3 +2615,263 @@ export type DownloadResticPasswordResponses = { }; export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses]; + +export type ExportFullConfigData = { + body?: { + password: string; + includeIds?: boolean; + includePasswordHash?: boolean; + includeRecoveryKey?: boolean; + includeRuntimeState?: boolean; + includeTimestamps?: boolean; + secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; + }; + path?: never; + query?: never; + url: '/api/v1/config/export'; +}; + +export type ExportFullConfigErrors = { + /** + * Password required for sensitive export options + */ + 401: { + error: string; + }; + /** + * Export failed + */ + 500: { + error: string; + }; +}; + +export type ExportFullConfigError = ExportFullConfigErrors[keyof ExportFullConfigErrors]; + +export type ExportFullConfigResponses = { + /** + * Full configuration export + */ + 200: { + admin?: { + username: string; + passwordHash?: string; + recoveryKey?: string; + } | null; + backupSchedules?: Array; + exportedAt?: string; + notificationDestinations?: Array; + repositories?: Array; + version?: number; + volumes?: Array; + }; +}; + +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; + }; +}; + +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; + }; +}; + +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; + }; +}; + +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; + }; +}; + +export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses]; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index b7faa489..3d3a208d 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -1,6 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; import { Download } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { + exportBackupSchedulesMutation, + exportFullConfigMutation, + exportNotificationDestinationsMutation, + exportRepositoriesMutation, + exportVolumesMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; import { Checkbox } from "~/client/components/ui/checkbox"; import { @@ -22,28 +30,8 @@ import { SelectValue, } from "~/client/components/ui/select"; -async function verifyPassword(password: string): Promise { - const response = await fetch("/api/v1/auth/verify-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - return response.ok; -} - export type SecretsMode = "exclude" | "encrypted" | "cleartext"; -export type ExportOptions = { - includeIds?: boolean; - includeTimestamps?: boolean; - includeRuntimeState?: boolean; - includeRecoveryKey?: boolean; - includePasswordHash?: boolean; - secretsMode?: SecretsMode; - name?: string; - id?: string | number; -}; - function downloadAsJson(data: unknown, filename: string): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); @@ -56,89 +44,51 @@ function downloadAsJson(data: unknown, filename: string): void { URL.revokeObjectURL(url); } -async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise { - const params = new URLSearchParams(); - if (options.includeIds === false) params.set("includeIds", "false"); - if (options.includeTimestamps === false) params.set("includeTimestamps", "false"); - if (options.includeRuntimeState === true) params.set("includeRuntimeState", "true"); - if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true"); - if (options.includePasswordHash === true) params.set("includePasswordHash", "true"); - if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode); - if (options.id !== undefined) params.set("id", String(options.id)); - if (options.name) params.set("name", options.name); - - const url = params.toString() ? `${endpoint}?${params}` : endpoint; - const res = await fetch(url, { credentials: "include" }); - - if (!res.ok) { - const errorText = await res.text().catch(() => res.statusText); - throw new Error(errorText || `HTTP ${res.status}`); - } - - const data = await res.json(); - downloadAsJson(data, filename); -} - export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full"; type ExportConfig = { - endpoint: string; label: string; labelPlural: string; - getFilename: (options: ExportOptions) => string; + getFilename: (id?: string | number, name?: string) => string; }; const exportConfigs: Record = { volumes: { - endpoint: "/api/v1/config/export/volumes", label: "Volume", labelPlural: "Volumes", - getFilename: (opts) => { - const identifier = opts.id ?? opts.name; + getFilename: (id, name) => { + const identifier = id ?? name; return identifier ? `volume-${identifier}-config` : "volumes-config"; }, }, repositories: { - endpoint: "/api/v1/config/export/repositories", label: "Repository", labelPlural: "Repositories", - getFilename: (opts) => { - const identifier = opts.id ?? opts.name; + getFilename: (id, name) => { + const identifier = id ?? name; return identifier ? `repository-${identifier}-config` : "repositories-config"; }, }, "notification-destinations": { - endpoint: "/api/v1/config/export/notification-destinations", label: "Notification Destination", labelPlural: "Notification Destinations", - getFilename: (opts) => { - const identifier = opts.id ?? opts.name; + getFilename: (id, name) => { + const identifier = id ?? name; return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config"; }, }, "backup-schedules": { - endpoint: "/api/v1/config/export/backup-schedules", label: "Backup Schedule", labelPlural: "Backup Schedules", - getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"), + getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"), }, full: { - endpoint: "/api/v1/config/export", label: "Full Config", labelPlural: "Full Config", getFilename: () => "zerobyte-full-config", }, }; -export async function exportConfig( - entityType: ExportEntityType, - options: ExportOptions = {} -): Promise { - const config = exportConfigs[entityType]; - const filename = config.getFilename(options); - await exportFromApi(config.endpoint, filename, options); -} - type BaseExportDialogProps = { entityType: ExportEntityType; name?: string; @@ -181,10 +131,7 @@ export function ExportDialog({ const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false); const [secretsMode, setSecretsMode] = useState("exclude"); - const [isExporting, setIsExporting] = useState(false); - const [showPasswordStep, setShowPasswordStep] = useState(false); const [password, setPassword] = useState(""); - const [isVerifying, setIsVerifying] = useState(false); const config = exportConfigs[entityType]; const isSingleItem = !!(name || id); @@ -192,71 +139,137 @@ export function ExportDialog({ // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes"; const entityLabel = isSingleItem ? config.label : config.labelPlural; - const requiresPassword = includeRecoveryKey || secretsMode === "cleartext"; + const filename = config.getFilename(id, name); - const performExport = async () => { - setIsExporting(true); - try { - await exportConfig(entityType, { - includeIds, - includeTimestamps, - includeRuntimeState, - includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined, - includePasswordHash: isFullExport ? includePasswordHash : undefined, - secretsMode: hasSecrets ? secretsMode : undefined, - name, - id, - }); - toast.success(`${entityLabel} exported successfully`); - setOpen(false); - setShowPasswordStep(false); - setPassword(""); - } catch (err) { - toast.error("Export failed", { - description: err instanceof Error ? err.message : String(err), - }); - } finally { - setIsExporting(false); + const handleExportSuccess = (data: unknown) => { + downloadAsJson(data, filename); + toast.success(`${entityLabel} exported successfully`); + setOpen(false); + setPassword(""); + }; + + const handleExportError = (error: unknown) => { + const message = error && typeof error === "object" && "error" in error + ? (error as { error: string }).error + : "Unknown error"; + toast.error("Export failed", { + description: message, + }); + }; + + const fullExport = useMutation({ + ...exportFullConfigMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const volumesExport = useMutation({ + ...exportVolumesMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const repositoriesExport = useMutation({ + ...exportRepositoriesMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const notificationsExport = useMutation({ + ...exportNotificationDestinationsMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const backupSchedulesExport = useMutation({ + ...exportBackupSchedulesMutation(), + onSuccess: handleExportSuccess, + onError: handleExportError, + }); + + const getMutation = () => { + switch (entityType) { + case "full": + return fullExport; + case "volumes": + return volumesExport; + case "repositories": + return repositoriesExport; + case "notification-destinations": + return notificationsExport; + case "backup-schedules": + return backupSchedulesExport; } }; - const handleExport = () => { - if (requiresPassword) { - setShowPasswordStep(true); - } else { - performExport(); - } - }; + const exportMutation = getMutation(); - const handlePasswordSubmit = async (e: React.FormEvent) => { + const handleExport = (e: React.FormEvent) => { e.preventDefault(); if (!password) { toast.error("Password is required"); return; } - setIsVerifying(true); - try { - const isValid = await verifyPassword(password); - if (!isValid) { - toast.error("Incorrect password"); - return; - } - // Password verified, proceed with export - await performExport(); - } catch (err) { - toast.error("Verification failed", { - description: err instanceof Error ? err.message : "Unable to verify password. Please check your connection and try again.", - }); - } finally { - setIsVerifying(false); + const baseBody = { + password, + includeIds, + includeTimestamps, + includeRuntimeState, + secretsMode: hasSecrets ? secretsMode : undefined, + }; + + switch (entityType) { + case "full": + fullExport.mutate({ + body: { + ...baseBody, + includeRecoveryKey, + includePasswordHash, + }, + }); + break; + case "volumes": + volumesExport.mutate({ + body: { + ...baseBody, + id: id as number | string | undefined, + name, + }, + }); + break; + case "repositories": + repositoriesExport.mutate({ + body: { + ...baseBody, + id: id as number | string | undefined, + name, + }, + }); + break; + case "notification-destinations": + notificationsExport.mutate({ + body: { + ...baseBody, + id: id as number | string | undefined, + name, + }, + }); + break; + case "backup-schedules": + backupSchedulesExport.mutate({ + body: { + ...baseBody, + id: typeof id === "number" ? id : undefined, + }, + }); + break; } }; const handleDialogChange = (isOpen: boolean) => { setOpen(isOpen); if (!isOpen) { - setShowPasswordStep(false); setPassword(""); } }; @@ -283,172 +296,146 @@ export function ExportDialog({ {trigger ?? defaultTrigger} - {showPasswordStep ? ( -
- - Confirm Export - - For security reasons, please enter your password to export sensitive information - - -
-
- - setPassword(e.target.value)} - placeholder="Enter your password" - required - autoFocus - /> -
+ + + Export {entityLabel} + + {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.`} + + + +
+
+ setIncludeIds(checked === true)} + /> +
- - - - - - ) : ( - <> - - Export {entityLabel} - - {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.`} - - +

+ Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. +

-
-
- setIncludeIds(checked === true)} - /> - -
-

- Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. -

- -
- setIncludeTimestamps(checked === true)} - /> - -
-

- Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. -

- -
- setIncludeRuntimeState(checked === true)} - /> - -
-

- Include current status, health checks, and last backup information. Usually not needed for migration. -

- - {hasSecrets && ( - <> -
- - -
-

- {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} - {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} - {secretsMode === "cleartext" && ( - - ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! - - )} -

- - )} - - {isFullExport && ( - <> -
- setIncludeRecoveryKey(checked === true)} - /> - -
-

- ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. -

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

- Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). -

- - )} +
+ setIncludeTimestamps(checked === true)} + /> +
+

+ Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. +

- - - - - - )} +
+ setIncludeRuntimeState(checked === true)} + /> + +
+

+ Include current status, health checks, and last backup information. Usually not needed for migration. +

+ + {hasSecrets && ( + <> +
+ + +
+

+ {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ + )} + + {isFullExport && ( + <> +
+ setIncludeRecoveryKey(checked === true)} + /> + +
+

+ ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. +

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

+ Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). +

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

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

+
+
+ + + + + +
); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index adf9c966..c78d2818 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -12,15 +12,12 @@ import { logoutDto, registerBodySchema, registerDto, - verifyPasswordBodySchema, - verifyPasswordDto, type ChangePasswordDto, type GetMeDto, type GetStatusDto, type LoginDto, type LogoutDto, type RegisterDto, - type VerifyPasswordDto, } from "./auth.dto"; import { authService } from "./auth.service"; import { toMessage } from "../../utils/errors"; @@ -141,27 +138,4 @@ export const authController = new Hono() } catch (error) { return c.json({ 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({ success: false, message: "Not authenticated" }, 401); - } - - const session = await authService.verifySession(sessionId); - - if (!session) { - deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); - return c.json({ 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({ success: false, message: "Incorrect password" }, 401); - } - - return c.json({ success: true, message: "Password verified" }); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index 846c204b..f305f9c1 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -148,34 +148,6 @@ export const changePasswordDto = describeRoute({ export type ChangePasswordDto = typeof changePasswordResponseSchema.infer; -export const verifyPasswordBodySchema = type({ - password: "string>0", -}); - -const verifyPasswordResponseSchema = type({ - success: "boolean", - message: "string", -}); - -export const verifyPasswordDto = describeRoute({ - description: "Verify current user password for re-authentication", - operationId: "verifyPassword", - tags: ["Auth"], - responses: { - 200: { - description: "Password verification result", - content: { - "application/json": { - schema: resolver(verifyPasswordResponseSchema), - }, - }, - }, - }, -}); - -export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer; - export type LoginBody = typeof loginBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer; export type ChangePasswordBody = typeof changePasswordBodySchema.infer; -export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer; diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index cd8e8c95..774b9206 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -1,24 +1,44 @@ +import { validator } from "hono-openapi"; + import { Hono } from "hono"; import type { Context } from "hono"; -import { eq } from "drizzle-orm"; +import { deleteCookie, getCookie } from "hono/cookie"; import { backupSchedulesTable, - notificationDestinationsTable, - repositoriesTable, backupScheduleNotificationsTable, usersTable, - volumesTable, } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { RESTIC_PASS_FILE } from "../../core/constants"; import { cryptoUtils } from "../../utils/crypto"; +import { authService } from "../auth/auth.service"; +import { volumeService } from "../volumes/volume.service"; +import { repositoriesService } from "../repositories/repositories.service"; +import { notificationsService } from "../notifications/notifications.service"; +import { backupsService } from "../backups/backups.service"; +import { + fullExportBodySchema, + entityExportBodySchema, + backupScheduleExportBodySchema, + fullExportDto, + volumesExportDto, + repositoriesExportDto, + notificationsExportDto, + backupSchedulesExportDto, + type SecretsMode, + type FullExportBody, + type EntityExportBody, + type BackupScheduleExportBody, +} from "./config-export.dto"; -// ============================================================================ -// Types -// ============================================================================ - -type SecretsMode = "exclude" | "encrypted" | "cleartext"; +const COOKIE_NAME = "session_id"; +const COOKIE_OPTIONS = { + httpOnly: true, + secure: false, + sameSite: "lax" as const, + path: "/", +}; type ExportParams = { includeIds: boolean; @@ -27,14 +47,6 @@ type ExportParams = { excludeKeys: string[]; }; -type FilterOptions = { id?: string; name?: string }; - -type FetchResult = { data: T[] } | { error: string; status: 400 | 404 }; - -// ============================================================================ -// Helper Functions -// ============================================================================ - function omitKeys>(obj: T, keys: string[]): Partial { const result = { ...obj }; for (const key of keys) { @@ -66,25 +78,46 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, include ]; } -/** Parse common export query parameters from request */ -function parseExportParams(c: Context): ExportParams { - const includeIds = c.req.query("includeIds") !== "false"; - const includeTimestamps = c.req.query("includeTimestamps") !== "false"; - const includeRuntimeState = c.req.query("includeRuntimeState") === "true"; - const secretsModeRaw = c.req.query("secretsMode"); - const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"]; - const secretsMode: SecretsMode = secretsModeRaw - ? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode) - ? (secretsModeRaw as SecretsMode) - : (() => { throw new Error("Invalid secretsMode parameter"); })()) - : "exclude"; +/** Parse export params from request body */ +function parseExportParamsFromBody(body: { + includeIds?: boolean; + includeTimestamps?: boolean; + includeRuntimeState?: boolean; + secretsMode?: SecretsMode; +}): ExportParams { + const includeIds = body.includeIds !== false; + const includeTimestamps = body.includeTimestamps !== false; + const includeRuntimeState = body.includeRuntimeState === true; + const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeIds, includeTimestamps, secretsMode, excludeKeys }; } -/** Get filter options from request query params */ -function getFilterOptions(c: Context): FilterOptions { - return { id: c.req.query("id"), name: c.req.query("name") }; +/** + * Verify password for export operation. + * All exports require password verification for security. + */ +async function verifyExportPassword( + c: Context, + password: string +): Promise<{ valid: true; userId: number } | { valid: false; error: string }> { + const sessionId = getCookie(c, COOKIE_NAME); + if (!sessionId) { + return { valid: false, error: "Not authenticated" }; + } + + const session = await authService.verifySession(sessionId); + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return { valid: false, error: "Session expired" }; + } + + const isValid = await authService.verifyPassword(session.user.id, password); + if (!isValid) { + return { valid: false, error: "Incorrect password" }; + } + + return { valid: true, userId: session.user.id }; } /** @@ -146,72 +179,6 @@ async function exportEntities>( return Promise.all(entities.map((e) => exportEntity(e as Record, params))); } -// ============================================================================ -// Data Fetchers with Filtering -// ============================================================================ - -async function fetchVolumes(filter: FilterOptions): Promise> { - 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> { - 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> { - 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> { - if (filter.id) { - const id = Number.parseInt(filter.id, 10); - if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 }; - const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id)); - if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 }; - return { data: result }; - } - return { data: await db.select().from(backupSchedulesTable) }; -} - /** Transform backup schedules with resolved names and notifications */ function transformBackupSchedules( schedules: typeof backupSchedulesTable.$inferSelect[], @@ -241,34 +208,27 @@ function transformBackupSchedules( }); } -// ============================================================================ -// Controller -// ============================================================================ - -/** - * Config Export API - * - * Query parameters: - * - includeIds: "true" | "false" (default: "true") - Include database IDs - * - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt - * - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only) - * - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only) - * - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets - * - id: string (optional) - Filter by ID - * - name: string (optional) - Filter by name (not for backups) - */ export const configExportController = new Hono() - .get("/export", async (c) => { + .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true"; - const includePasswordHash = c.req.query("includePasswordHash") === "true"; + const body = c.req.valid("json") as FullExportBody; + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + const includeRecoveryKey = body.includeRecoveryKey === true; + const includePasswordHash = body.includePasswordHash === true; + + // Use services to fetch data const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ - db.select().from(volumesTable), - db.select().from(repositoriesTable), - db.select().from(backupSchedulesTable), - db.select().from(notificationDestinationsTable), + volumeService.listVolumes(), + repositoriesService.listRepositories(), + backupsService.listSchedules(), + notificationsService.listDestinations(), db.select().from(backupScheduleNotificationsTable), db.select().from(usersTable).limit(1), ]); @@ -281,9 +241,6 @@ export const configExportController = new Hono() backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); - // WARNING: As of now, volume exports may include sensitive data (e.g., SMB/NFS credentials) in cleartext. - // This is a known security limitation. Handle exported configuration files with care. - // Future PRs will implement encryption for these secrets. const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), @@ -318,60 +275,169 @@ export const configExportController = new Hono() return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); } }) - .get("/export/volumes", async (c) => { + .post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchVolumes(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR - return c.json({ volumes: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let volumes; + // Prefer name over id since volumeService.getVolume expects name (slug) + if (body.name) { + try { + const result = await volumeService.getVolume(body.name); + volumes = [result.volume]; + } catch { + return c.json({ error: `Volume '${body.name}' not found` }, 404); + } + } else if (body.id !== undefined) { + // If only ID provided, find volume by numeric ID from list + const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; + if (Number.isNaN(id)) { + return c.json({ error: "Invalid volume ID" }, 400); + } + const allVolumes = await volumeService.listVolumes(); + const volume = allVolumes.find((v) => v.id === id); + if (!volume) { + return c.json({ error: `Volume with ID '${body.id}' not found` }, 404); + } + volumes = [volume]; + } else { + volumes = await volumeService.listVolumes(); + } + + return c.json({ volumes: await exportEntities(volumes, params) }); } catch (err) { logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/repositories", async (c) => { + .post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchRepositories(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json({ repositories: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let repositories; + // Prefer name over id since repositoriesService.getRepository expects name (slug) + if (body.name) { + try { + const result = await repositoriesService.getRepository(body.name); + repositories = [result.repository]; + } catch { + return c.json({ error: `Repository '${body.name}' not found` }, 404); + } + } else if (body.id !== undefined) { + // If only ID provided, find repository by ID from list + // Repository IDs are strings (UUIDs), not numeric + const allRepositories = await repositoriesService.listRepositories(); + const repository = allRepositories.find((r) => r.id === String(body.id)); + if (!repository) { + return c.json({ error: `Repository with ID '${body.id}' not found` }, 404); + } + repositories = [repository]; + } else { + repositories = await repositoriesService.listRepositories(); + } + + return c.json({ repositories: await exportEntities(repositories, params) }); } catch (err) { logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/notification-destinations", async (c) => { + .post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchNotifications(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json({ notificationDestinations: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let notifications; + if (body.id !== undefined) { + const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; + if (Number.isNaN(id)) { + return c.json({ error: "Invalid notification destination ID" }, 400); + } + try { + const destination = await notificationsService.getDestination(id); + notifications = [destination]; + } catch { + return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404); + } + } else if (body.name) { + // notificationsService doesn't have getByName, so we list and filter + const allDestinations = await notificationsService.listDestinations(); + const destination = allDestinations.find((d) => d.name === body.name); + if (!destination) { + return c.json({ error: `Notification destination '${body.name}' not found` }, 404); + } + notifications = [destination]; + } else { + notifications = await notificationsService.listDestinations(); + } + + return c.json({ notificationDestinations: await exportEntities(notifications, params) }); } catch (err) { logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/backup-schedules", async (c) => { + .post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => { try { - const params = parseExportParams(c); + const body = c.req.valid("json") as BackupScheduleExportBody; + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + // Get all related data for name resolution const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ - db.select().from(volumesTable), - db.select().from(repositoriesTable), - db.select().from(notificationDestinationsTable), + volumeService.listVolumes(), + repositoriesService.listRepositories(), + notificationsService.listDestinations(), db.select().from(backupScheduleNotificationsTable), ]); - const result = await fetchBackupSchedules({ id: c.req.query("id") }); - if ("error" in result) return c.json({ error: result.error }, result.status); + let schedules; + if (body.id !== undefined) { + try { + const schedule = await backupsService.getSchedule(body.id); + schedules = [schedule]; + } catch { + return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404); + } + } else { + schedules = await backupsService.listSchedules(); + } const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); const repoMap = new Map(repositories.map((r) => [r.id, r.name])); const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); const backupSchedules = transformBackupSchedules( - result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params + schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); return c.json({ backupSchedules }); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts new file mode 100644 index 00000000..f0e66859 --- /dev/null +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -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), + }, + }, + }, + }, +});