From 081a10afebf01d59b5ad96322517c0c6a648a22d Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 4 Jan 2026 12:45:36 +0100 Subject: [PATCH] refactor: simplify export process to minimal working concept --- .../api-client/@tanstack/react-query.gen.ts | 4 +- app/client/api-client/sdk.gen.ts | 2 +- app/client/api-client/types.gen.ts | 3 - app/client/components/export-dialog.tsx | 168 ++------------- app/client/lib/utils.ts | 17 ++ .../auth/routes/download-recovery-key.tsx | 12 +- .../modules/settings/routes/settings.tsx | 14 +- .../lifecycle/config-export.controller.ts | 197 ++++-------------- .../modules/lifecycle/config-export.dto.ts | 41 ++-- 9 files changed, 99 insertions(+), 359 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index b2274781..f6232e3d 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, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 866fcc3d..37bc62e0 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, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 6e757eec..2c962600 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -3342,9 +3342,6 @@ export type ExportFullConfigData = { body?: { password: string; includeMetadata?: boolean; - includePasswordHash?: boolean; - includeRecoveryKey?: boolean; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; }; path?: never; query?: never; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 89dd2ef5..32c2b4ea 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { Download } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; @@ -16,82 +16,29 @@ import { } from "~/client/components/ui/dialog"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; - -type SecretsMode = "exclude" | "encrypted" | "cleartext"; +import { parseError } from "../lib/errors"; +import { downloadFile } from "../lib/utils"; const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config"; -function downloadAsJson(data: unknown, filename: string): void { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${filename}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -type BaseExportDialogProps = { - /** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */ - filename?: string; -}; - -type ExportDialogWithTrigger = BaseExportDialogProps & { - /** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */ - trigger: ReactNode; - variant?: never; - size?: never; - triggerLabel?: never; - showIcon?: never; -}; - -type ExportDialogWithDefaultTrigger = BaseExportDialogProps & { - trigger?: never; - variant?: "default" | "outline" | "ghost" | "card"; - size?: "default" | "sm" | "lg" | "icon"; - triggerLabel?: string; - showIcon?: boolean; -}; - -type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger; - -export function ExportDialog({ - filename = DEFAULT_EXPORT_FILENAME, - trigger, - variant = "outline", - size = "default", - triggerLabel, - showIcon = true, -}: ExportDialogProps) { +export const ExportDialog = () => { const [open, setOpen] = useState(false); const [includeMetadata, setIncludeMetadata] = useState(false); - const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); - const [includePasswordHash, setIncludePasswordHash] = useState(false); - const [secretsMode, setSecretsMode] = useState("exclude"); const [password, setPassword] = useState(""); - const handleExportSuccess = (data: unknown) => { - downloadAsJson(data, filename); - toast.success("Configuration exported successfully"); - setOpen(false); - setPassword(""); - }; - - const handleExportError = (error: unknown) => { - const message = - error && typeof error === "object" && "error" in error ? (error as { error: string }).error : "Unknown error"; - toast.error("Export failed", { - description: message, - }); - }; - const exportMutation = useMutation({ ...exportFullConfigMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, + onSuccess: (data) => { + downloadFile(data, `${DEFAULT_EXPORT_FILENAME}.json`, "application/json"); + toast.success("Configuration exported successfully"); + setOpen(false); + setPassword(""); + }, + onError: (e) => { + toast.error("Export failed", { + description: parseError(e)?.message, + }); + }, }); const handleExport = (e: React.FormEvent) => { @@ -105,9 +52,6 @@ export function ExportDialog({ body: { password, includeMetadata, - secretsMode, - includeRecoveryKey, - includePasswordHash, }, }); }; @@ -119,25 +63,14 @@ export function ExportDialog({ } }; - const defaultTrigger = - variant === "card" ? ( - - ) : ( - - ); - return ( - {trigger ?? defaultTrigger} + + +
@@ -157,62 +90,7 @@ export function ExportDialog({

- Include database IDs, timestamps, and runtime state (status, health checks, last backup info). -

- -
- - -
-

- {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! - - )} -

- -
- 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 user passwords for seamless migration. The passwords are already securely hashed - (argon2). + Include timestamps and runtime state (status, health checks, last backup info).

@@ -244,4 +122,4 @@ export function ExportDialog({
); -} +}; diff --git a/app/client/lib/utils.ts b/app/client/lib/utils.ts index 211eb300..79767062 100644 --- a/app/client/lib/utils.ts +++ b/app/client/lib/utils.ts @@ -28,3 +28,20 @@ export function slugify(input: string): string { .replace(/[_]{2,}/g, "_") .trim(); } + +type DownloadFileMimeType = "text/plain" | "application/json"; +export const downloadFile = (data: unknown, filename: string, mimeType: DownloadFileMimeType = "text/plain") => { + const content = mimeType === "application/json" && typeof data !== "string" ? JSON.stringify(data, null, 2) : data; + + const blob = new Blob([content as BlobPart], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 5235702a..05c59175 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -11,6 +11,7 @@ import { Label } from "~/client/components/ui/label"; import { authMiddleware } from "~/middleware/auth"; import type { Route } from "./+types/download-recovery-key"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { downloadFile } from "~/client/lib/utils"; export const clientMiddleware = [authMiddleware]; @@ -31,16 +32,7 @@ export default function DownloadRecoveryKeyPage() { const downloadResticPassword = useMutation({ ...downloadResticPasswordMutation(), onSuccess: (data) => { - const blob = new Blob([data], { type: "text/plain" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "restic.pass"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - + downloadFile(data, "restic.pass"); toast.success("Recovery key downloaded successfully!"); navigate("/volumes", { replace: true }); }, diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index a44280f9..f8b36ede 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -24,6 +24,7 @@ import { downloadResticPasswordMutation, logoutMutation, } from "~/client/api-client/@tanstack/react-query.gen"; +import { downloadFile } from "~/client/lib/utils"; export const handle = { breadcrumb: () => [{ label: "Settings" }], @@ -79,16 +80,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const downloadResticPassword = useMutation({ ...downloadResticPasswordMutation(), onSuccess: (data) => { - const blob = new Blob([data], { type: "text/plain" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "restic.pass"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - + downloadFile(data, "restic.pass"); toast.success("Restic password file downloaded successfully"); setDownloadDialogOpen(false); setDownloadPassword(""); @@ -279,7 +271,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) { Export all your volumes, repositories, backup schedules, and notification settings to a JSON file. This can be used to restore your configuration on a new instance or as a backup of your settings.

- + ); diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index f7e70d8c..1bce1ed4 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -2,31 +2,25 @@ import { validator } from "hono-openapi"; import { Hono } from "hono"; import type { Context } from "hono"; import { - type backupSchedulesTable, backupScheduleNotificationsTable, backupScheduleMirrorsTable, usersTable, + type BackupSchedule, + type BackupScheduleNotification, + type BackupScheduleMirror, + volumesTable, + repositoriesTable, + backupSchedulesTable, + notificationDestinationsTable, } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; -import { RESTIC_PASS_FILE, REPOSITORY_BASE } 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, fullExportDto, type SecretsMode, type FullExportBody } from "./config-export.dto"; +import { fullExportBodySchema, fullExportDto, type FullExportBody } from "./config-export.dto"; import { requireAuth } from "../auth/auth.middleware"; +import { toMessage } from "~/server/utils/errors"; -type ExportParams = { - includeMetadata: boolean; - secretsMode: SecretsMode; -}; - -// Keys to exclude when metadata is not included const METADATA_KEYS = { - ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"], timestamps: [ "createdAt", "updatedAt", @@ -48,9 +42,8 @@ const METADATA_KEYS = { ], }; -const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; +const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; -/** Filter out metadata keys from an object when includeMetadata is false */ function filterMetadataOut>(obj: T, includeMetadata: boolean): Partial { if (includeMetadata) { return obj; @@ -62,22 +55,10 @@ function filterMetadataOut>(obj: T, includeMet return result; } -/** Parse export params from request body */ -function parseExportParamsFromBody(body: { includeMetadata?: boolean; secretsMode?: SecretsMode }): ExportParams { - const includeMetadata = body.includeMetadata === true; - const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; - return { includeMetadata, secretsMode }; -} - -/** - * Verify password for export operation. - * Requires requireAuth middleware to have already validated the session. - */ async function verifyExportPassword( c: Context, password: string, ): Promise<{ valid: true; userId: number } | { valid: false; error: string }> { - // requireAuth middleware ensures c.get('user') exists const user = c.get("user"); if (!user) { return { valid: false, error: "Not authenticated" }; @@ -91,184 +72,76 @@ async function verifyExportPassword( return { valid: true, userId: user.id }; } -/** - * Process secrets in an object based on the secrets mode. - * Automatically detects encrypted fields using cryptoUtils.isEncrypted. - */ -async function processSecrets( - obj: Record, - secretsMode: SecretsMode, -): Promise> { - if (secretsMode === "encrypted") { - return obj; - } - - const result = { ...obj }; - - for (const [key, value] of Object.entries(result)) { - if (typeof value === "string" && cryptoUtils.isEncrypted(value)) { - if (secretsMode === "exclude") { - delete result[key]; - } else if (secretsMode === "cleartext") { - try { - result[key] = await cryptoUtils.decrypt(value); - } catch (err) { - logger.warn(`Failed to decrypt field "${key}": ${err instanceof Error ? err.message : String(err)}`); - delete result[key]; - } - } - } else if (Array.isArray(value)) { - result[key] = await Promise.all( - value.map(async (item) => - item && typeof item === "object" && !Array.isArray(item) - ? processSecrets(item as Record, secretsMode) - : item, - ), - ); - } else if (value && typeof value === "object") { - result[key] = await processSecrets(value as Record, secretsMode); - } - } - - return result; +async function exportEntity(entity: Record, params: FullExportBody) { + return filterMetadataOut(entity, params.includeMetadata); } -/** Clean and process an entity for export */ -async function exportEntity(entity: Record, params: ExportParams): Promise> { - const cleaned = filterMetadataOut(entity, params.includeMetadata); - return processSecrets(cleaned, params.secretsMode); +async function exportEntities>(entities: T[], params: FullExportBody) { + return Promise.all(entities.map((e) => exportEntity(e, params))); } -/** Export multiple entities */ -async function exportEntities>( - entities: T[], - params: ExportParams, -): Promise[]> { - return Promise.all(entities.map((e) => exportEntity(e as Record, params))); -} - -/** Transform backup schedules with resolved names, notifications, and mirrors */ -function transformBackupSchedules( - schedules: (typeof backupSchedulesTable.$inferSelect)[], - scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[], - scheduleMirrors: (typeof backupScheduleMirrorsTable.$inferSelect)[], - volumeMap: Map, - repoMap: Map, - notificationMap: Map, - params: ExportParams, -) { +const transformBackupSchedules = ( + schedules: BackupSchedule[], + scheduleNotifications: BackupScheduleNotification[], + scheduleMirrors: BackupScheduleMirror[], + params: FullExportBody, +) => { return schedules.map((schedule) => { const assignments = scheduleNotifications .filter((sn) => sn.scheduleId === schedule.id) - .map((sn) => ({ - ...filterMetadataOut(sn as unknown as Record, params.includeMetadata), - name: notificationMap.get(sn.destinationId) ?? null, - })); + .map((sn) => filterMetadataOut(sn, params.includeMetadata)); const mirrors = scheduleMirrors .filter((sm) => sm.scheduleId === schedule.id) - .map((sm) => ({ - ...filterMetadataOut(sm as unknown as Record, params.includeMetadata), - repository: repoMap.get(sm.repositoryId) ?? null, - })); + .map((sm) => filterMetadataOut(sm, params.includeMetadata)); return { - ...filterMetadataOut(schedule as Record, params.includeMetadata), - volume: volumeMap.get(schedule.volumeId) ?? null, - repository: repoMap.get(schedule.repositoryId) ?? null, + ...filterMetadataOut(schedule, params.includeMetadata), notifications: assignments, mirrors, }; }); -} +}; export const configExportController = new Hono() .use(requireAuth) .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { try { - const body = c.req.valid("json") as FullExportBody; + const params = c.req.valid("json"); - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); + const verification = await verifyExportPassword(c, params.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, scheduleMirrors, users] = await Promise.all([ - volumeService.listVolumes(), - repositoriesService.listRepositories(), - backupsService.listSchedules(), - notificationsService.listDestinations(), + db.select().from(volumesTable), + db.select().from(repositoriesTable), + db.select().from(backupSchedulesTable), + db.select().from(notificationDestinationsTable), db.select().from(backupScheduleNotificationsTable), db.select().from(backupScheduleMirrorsTable), db.select().from(usersTable), ]); - const 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( backupSchedulesRaw, scheduleNotifications, scheduleMirrors, - volumeMap, - repoMap, - notificationMap, params, ); - const [exportVolumes, exportRepositoriesRaw, exportNotifications] = await Promise.all([ + const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), exportEntities(notifications, params), + exportEntities(users, params), ]); - // Add isExistingRepository flag and path to all repository configs for import compatibility - const exportRepositories = exportRepositoriesRaw.map((repo) => { - if (!repo.config || typeof repo.config !== "object") { - return repo; - } - - const config = repo.config as Record; - const updatedConfig: Record = { ...config, isExistingRepository: true }; - - // For local repos, compute and add the full path if not already present - if (config.backend === "local" && !config.path && typeof config.name === "string") { - updatedConfig.path = `${REPOSITORY_BASE}/${config.name}`; - } - - return { ...repo, config: updatedConfig }; - }); - - let recoveryKey: string | undefined; - if (includeRecoveryKey) { - try { - recoveryKey = await Bun.file(RESTIC_PASS_FILE).text(); - logger.warn("Recovery key exported - this is a security-sensitive operation"); - } catch { - logger.warn("Could not read recovery key file"); - } - } - - // Users need special handling for passwordHash (controlled by separate flag) - const exportUsers = (await exportEntities(users, params)).map((user) => { - if (!includePasswordHash) { - delete user.passwordHash; - } - return user; - }); - return c.json({ version: 1, - ...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}), - ...(recoveryKey ? { recoveryKey } : {}), + exportedAt: new Date().toISOString(), volumes: exportVolumes, repositories: exportRepositories, backupSchedules, @@ -276,7 +149,7 @@ export const configExportController = new Hono() users: exportUsers, }); } catch (err) { - logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); + logger.error(`Config export failed: ${toMessage(err)}`); + return c.json({ error: toMessage(err) }, 500); } }); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index f7a57561..abdde5cc 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -1,40 +1,31 @@ import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; -const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); - export const fullExportBodySchema = type({ - /** Include metadata (IDs, timestamps, runtime state) in export (default: false) */ - "includeMetadata?": "boolean", - /** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */ - "secretsMode?": secretsModeSchema, - /** Password required for authentication */ + includeMetadata: "boolean = false", password: "string", - /** Include the recovery key */ - "includeRecoveryKey?": "boolean", - /** Include the user password hash */ - "includePasswordHash?": "boolean", }); export type FullExportBody = typeof fullExportBodySchema.infer; -export type SecretsMode = typeof secretsModeSchema.infer; const exportResponseSchema = type({ version: "number", - "exportedAt?": "string", - "recoveryKey?": "string", - "volumes?": "unknown[]", - "repositories?": "unknown[]", - "backupSchedules?": "unknown[]", - "notificationDestinations?": "unknown[]", - "users?": type({ - "id?": "number", + exportedAt: "string?", + recoveryKey: "string?", + volumes: "unknown[]?", + repositories: "unknown[]?", + backupSchedules: "unknown[]?", + notificationDestinations: "unknown[]?", + users: type({ + id: "number?", username: "string", - "passwordHash?": "string", - "createdAt?": "number", - "updatedAt?": "number", - "hasDownloadedResticPassword?": "boolean", - }).array(), + passwordHash: "string?", + createdAt: "number?", + updatedAt: "number?", + hasDownloadedResticPassword: "boolean?", + }) + .array() + .optional(), }); const errorResponseSchema = type({