refactor: simplify export process to minimal working concept
This commit is contained in:
parent
20c3b8156d
commit
081a10afeb
9 changed files with 99 additions and 359 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3342,9 +3342,6 @@ export type ExportFullConfigData = {
|
|||
body?: {
|
||||
password: string;
|
||||
includeMetadata?: boolean;
|
||||
includePasswordHash?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -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<SecretsMode>("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" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full border-0 bg-transparent p-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Download className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{triggerLabel ?? "Export Config"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<Button variant={variant} size={size}>
|
||||
{showIcon && <Download className="h-4 w-4 mr-2" />}
|
||||
{triggerLabel ?? "Export Config"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export configuration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleExport}>
|
||||
<DialogHeader>
|
||||
|
|
@ -157,62 +90,7 @@ export function ExportDialog({
|
|||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<Label className="cursor-pointer">Secrets handling</Label>
|
||||
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">Exclude</SelectItem>
|
||||
<SelectItem value="encrypted">Keep encrypted</SelectItem>
|
||||
<SelectItem value="cleartext">Decrypt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{secretsMode === "exclude" &&
|
||||
"Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
|
||||
{secretsMode === "encrypted" &&
|
||||
"Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
|
||||
{secretsMode === "cleartext" && (
|
||||
<span className="text-yellow-600">
|
||||
⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure!
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-2 border-t">
|
||||
<Checkbox
|
||||
id="includeRecoveryKey"
|
||||
checked={includeRecoveryKey}
|
||||
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
|
||||
Include recovery key
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
<span className="text-yellow-600 font-medium">⚠️ Security sensitive:</span> The recovery key is the master
|
||||
encryption key for all repositories. Keep this export secure and never share it.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includePasswordHash"
|
||||
checked={includePasswordHash}
|
||||
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includePasswordHash" className="cursor-pointer">
|
||||
Include password hash
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include the hashed user passwords for seamless migration. The passwords are already securely hashed
|
||||
(argon2).
|
||||
Include timestamps and runtime state (status, health checks, last backup info).
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
|
|
@ -244,4 +122,4 @@ export function ExportDialog({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
<ExportDialog triggerLabel="Export Configuration" />
|
||||
<ExportDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
|
||||
if (includeMetadata) {
|
||||
return obj;
|
||||
|
|
@ -62,22 +55,10 @@ function filterMetadataOut<T extends Record<string, unknown>>(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<string, unknown>,
|
||||
secretsMode: SecretsMode,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>, secretsMode)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} else if (value && typeof value === "object") {
|
||||
result[key] = await processSecrets(value as Record<string, unknown>, secretsMode);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
async function exportEntity(entity: Record<string, unknown>, params: FullExportBody) {
|
||||
return filterMetadataOut(entity, params.includeMetadata);
|
||||
}
|
||||
|
||||
/** Clean and process an entity for export */
|
||||
async function exportEntity(entity: Record<string, unknown>, params: ExportParams): Promise<Record<string, unknown>> {
|
||||
const cleaned = filterMetadataOut(entity, params.includeMetadata);
|
||||
return processSecrets(cleaned, params.secretsMode);
|
||||
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: FullExportBody) {
|
||||
return Promise.all(entities.map((e) => exportEntity(e, params)));
|
||||
}
|
||||
|
||||
/** Export multiple entities */
|
||||
async function exportEntities<T extends Record<string, unknown>>(
|
||||
entities: T[],
|
||||
params: ExportParams,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return Promise.all(entities.map((e) => exportEntity(e as Record<string, unknown>, 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<number, string>,
|
||||
repoMap: Map<string, string>,
|
||||
notificationMap: Map<number, string>,
|
||||
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<string, unknown>, 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<string, unknown>, params.includeMetadata),
|
||||
repository: repoMap.get(sm.repositoryId) ?? null,
|
||||
}));
|
||||
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
|
||||
|
||||
return {
|
||||
...filterMetadataOut(schedule as Record<string, unknown>, 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<number, string>(volumes.map((v) => [v.id, v.name]));
|
||||
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
|
||||
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
|
||||
|
||||
const backupSchedules = transformBackupSchedules(
|
||||
backupSchedulesRaw,
|
||||
scheduleNotifications,
|
||||
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<string, unknown>;
|
||||
const updatedConfig: Record<string, unknown> = { ...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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue