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 { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, 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 { 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, 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 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
|
* Register a new user
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, 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> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3342,9 +3342,6 @@ export type ExportFullConfigData = {
|
||||||
body?: {
|
body?: {
|
||||||
password: string;
|
password: string;
|
||||||
includeMetadata?: boolean;
|
includeMetadata?: boolean;
|
||||||
includePasswordHash?: boolean;
|
|
||||||
includeRecoveryKey?: boolean;
|
|
||||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { type ReactNode, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
|
@ -16,82 +16,29 @@ import {
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { parseError } from "../lib/errors";
|
||||||
|
import { downloadFile } from "../lib/utils";
|
||||||
type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
|
||||||
|
|
||||||
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
|
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
|
||||||
|
|
||||||
function downloadAsJson(data: unknown, filename: string): void {
|
export const ExportDialog = () => {
|
||||||
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) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [includeMetadata, setIncludeMetadata] = 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 [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({
|
const exportMutation = useMutation({
|
||||||
...exportFullConfigMutation(),
|
...exportFullConfigMutation(),
|
||||||
onSuccess: handleExportSuccess,
|
onSuccess: (data) => {
|
||||||
onError: handleExportError,
|
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) => {
|
const handleExport = (e: React.FormEvent) => {
|
||||||
|
|
@ -105,9 +52,6 @@ export function ExportDialog({
|
||||||
body: {
|
body: {
|
||||||
password,
|
password,
|
||||||
includeMetadata,
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
<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>
|
<DialogContent>
|
||||||
<form onSubmit={handleExport}>
|
<form onSubmit={handleExport}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -157,62 +90,7 @@ export function ExportDialog({
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground ml-7">
|
<p className="text-xs text-muted-foreground ml-7">
|
||||||
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
|
Include 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).
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 pt-2 border-t">
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
|
@ -244,4 +122,4 @@ export function ExportDialog({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,20 @@ export function slugify(input: string): string {
|
||||||
.replace(/[_]{2,}/g, "_")
|
.replace(/[_]{2,}/g, "_")
|
||||||
.trim();
|
.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 { authMiddleware } from "~/middleware/auth";
|
||||||
import type { Route } from "./+types/download-recovery-key";
|
import type { Route } from "./+types/download-recovery-key";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { downloadFile } from "~/client/lib/utils";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -31,16 +32,7 @@ export default function DownloadRecoveryKeyPage() {
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const blob = new Blob([data], { type: "text/plain" });
|
downloadFile(data, "restic.pass");
|
||||||
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);
|
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
navigate("/volumes", { replace: true });
|
navigate("/volumes", { replace: true });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
downloadResticPasswordMutation,
|
downloadResticPasswordMutation,
|
||||||
logoutMutation,
|
logoutMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { downloadFile } from "~/client/lib/utils";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: () => [{ label: "Settings" }],
|
breadcrumb: () => [{ label: "Settings" }],
|
||||||
|
|
@ -79,16 +80,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const blob = new Blob([data], { type: "text/plain" });
|
downloadFile(data, "restic.pass");
|
||||||
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);
|
|
||||||
|
|
||||||
toast.success("Restic password file downloaded successfully");
|
toast.success("Restic password file downloaded successfully");
|
||||||
setDownloadDialogOpen(false);
|
setDownloadDialogOpen(false);
|
||||||
setDownloadPassword("");
|
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
|
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.
|
used to restore your configuration on a new instance or as a backup of your settings.
|
||||||
</p>
|
</p>
|
||||||
<ExportDialog triggerLabel="Export Configuration" />
|
<ExportDialog />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,25 @@ import { validator } from "hono-openapi";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
type backupSchedulesTable,
|
|
||||||
backupScheduleNotificationsTable,
|
backupScheduleNotificationsTable,
|
||||||
backupScheduleMirrorsTable,
|
backupScheduleMirrorsTable,
|
||||||
usersTable,
|
usersTable,
|
||||||
|
type BackupSchedule,
|
||||||
|
type BackupScheduleNotification,
|
||||||
|
type BackupScheduleMirror,
|
||||||
|
volumesTable,
|
||||||
|
repositoriesTable,
|
||||||
|
backupSchedulesTable,
|
||||||
|
notificationDestinationsTable,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { RESTIC_PASS_FILE, REPOSITORY_BASE } from "../../core/constants";
|
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
|
||||||
import { authService } from "../auth/auth.service";
|
import { authService } from "../auth/auth.service";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { fullExportBodySchema, fullExportDto, type FullExportBody } from "./config-export.dto";
|
||||||
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 { requireAuth } from "../auth/auth.middleware";
|
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 = {
|
const METADATA_KEYS = {
|
||||||
ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"],
|
|
||||||
timestamps: [
|
timestamps: [
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"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> {
|
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
|
||||||
if (includeMetadata) {
|
if (includeMetadata) {
|
||||||
return obj;
|
return obj;
|
||||||
|
|
@ -62,22 +55,10 @@ function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMet
|
||||||
return result;
|
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(
|
async function verifyExportPassword(
|
||||||
c: Context,
|
c: Context,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
|
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
|
||||||
// requireAuth middleware ensures c.get('user') exists
|
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return { valid: false, error: "Not authenticated" };
|
return { valid: false, error: "Not authenticated" };
|
||||||
|
|
@ -91,184 +72,76 @@ async function verifyExportPassword(
|
||||||
return { valid: true, userId: user.id };
|
return { valid: true, userId: user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function exportEntity(entity: Record<string, unknown>, params: FullExportBody) {
|
||||||
* Process secrets in an object based on the secrets mode.
|
return filterMetadataOut(entity, params.includeMetadata);
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clean and process an entity for export */
|
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: FullExportBody) {
|
||||||
async function exportEntity(entity: Record<string, unknown>, params: ExportParams): Promise<Record<string, unknown>> {
|
return Promise.all(entities.map((e) => exportEntity(e, params)));
|
||||||
const cleaned = filterMetadataOut(entity, params.includeMetadata);
|
|
||||||
return processSecrets(cleaned, params.secretsMode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export multiple entities */
|
const transformBackupSchedules = (
|
||||||
async function exportEntities<T extends Record<string, unknown>>(
|
schedules: BackupSchedule[],
|
||||||
entities: T[],
|
scheduleNotifications: BackupScheduleNotification[],
|
||||||
params: ExportParams,
|
scheduleMirrors: BackupScheduleMirror[],
|
||||||
): Promise<Record<string, unknown>[]> {
|
params: FullExportBody,
|
||||||
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,
|
|
||||||
) {
|
|
||||||
return schedules.map((schedule) => {
|
return schedules.map((schedule) => {
|
||||||
const assignments = scheduleNotifications
|
const assignments = scheduleNotifications
|
||||||
.filter((sn) => sn.scheduleId === schedule.id)
|
.filter((sn) => sn.scheduleId === schedule.id)
|
||||||
.map((sn) => ({
|
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
|
||||||
...filterMetadataOut(sn as unknown as Record<string, unknown>, params.includeMetadata),
|
|
||||||
name: notificationMap.get(sn.destinationId) ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mirrors = scheduleMirrors
|
const mirrors = scheduleMirrors
|
||||||
.filter((sm) => sm.scheduleId === schedule.id)
|
.filter((sm) => sm.scheduleId === schedule.id)
|
||||||
.map((sm) => ({
|
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
|
||||||
...filterMetadataOut(sm as unknown as Record<string, unknown>, params.includeMetadata),
|
|
||||||
repository: repoMap.get(sm.repositoryId) ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...filterMetadataOut(schedule as Record<string, unknown>, params.includeMetadata),
|
...filterMetadataOut(schedule, params.includeMetadata),
|
||||||
volume: volumeMap.get(schedule.volumeId) ?? null,
|
|
||||||
repository: repoMap.get(schedule.repositoryId) ?? null,
|
|
||||||
notifications: assignments,
|
notifications: assignments,
|
||||||
mirrors,
|
mirrors,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
export const configExportController = new Hono()
|
export const configExportController = new Hono()
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
||||||
try {
|
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, params.password);
|
||||||
const verification = await verifyExportPassword(c, body.password);
|
|
||||||
if (!verification.valid) {
|
if (!verification.valid) {
|
||||||
return c.json({ error: verification.error }, 401);
|
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] =
|
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
volumeService.listVolumes(),
|
db.select().from(volumesTable),
|
||||||
repositoriesService.listRepositories(),
|
db.select().from(repositoriesTable),
|
||||||
backupsService.listSchedules(),
|
db.select().from(backupSchedulesTable),
|
||||||
notificationsService.listDestinations(),
|
db.select().from(notificationDestinationsTable),
|
||||||
db.select().from(backupScheduleNotificationsTable),
|
db.select().from(backupScheduleNotificationsTable),
|
||||||
db.select().from(backupScheduleMirrorsTable),
|
db.select().from(backupScheduleMirrorsTable),
|
||||||
db.select().from(usersTable),
|
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(
|
const backupSchedules = transformBackupSchedules(
|
||||||
backupSchedulesRaw,
|
backupSchedulesRaw,
|
||||||
scheduleNotifications,
|
scheduleNotifications,
|
||||||
scheduleMirrors,
|
scheduleMirrors,
|
||||||
volumeMap,
|
|
||||||
repoMap,
|
|
||||||
notificationMap,
|
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [exportVolumes, exportRepositoriesRaw, exportNotifications] = await Promise.all([
|
const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([
|
||||||
exportEntities(volumes, params),
|
exportEntities(volumes, params),
|
||||||
exportEntities(repositories, params),
|
exportEntities(repositories, params),
|
||||||
exportEntities(notifications, 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({
|
return c.json({
|
||||||
version: 1,
|
version: 1,
|
||||||
...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}),
|
exportedAt: new Date().toISOString(),
|
||||||
...(recoveryKey ? { recoveryKey } : {}),
|
|
||||||
volumes: exportVolumes,
|
volumes: exportVolumes,
|
||||||
repositories: exportRepositories,
|
repositories: exportRepositories,
|
||||||
backupSchedules,
|
backupSchedules,
|
||||||
|
|
@ -276,7 +149,7 @@ export const configExportController = new Hono()
|
||||||
users: exportUsers,
|
users: exportUsers,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
|
logger.error(`Config export failed: ${toMessage(err)}`);
|
||||||
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
|
return c.json({ error: toMessage(err) }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,31 @@
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
|
|
||||||
|
|
||||||
export const fullExportBodySchema = type({
|
export const fullExportBodySchema = type({
|
||||||
/** Include metadata (IDs, timestamps, runtime state) in export (default: false) */
|
includeMetadata: "boolean = false",
|
||||||
"includeMetadata?": "boolean",
|
|
||||||
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
|
|
||||||
"secretsMode?": secretsModeSchema,
|
|
||||||
/** Password required for authentication */
|
|
||||||
password: "string",
|
password: "string",
|
||||||
/** Include the recovery key */
|
|
||||||
"includeRecoveryKey?": "boolean",
|
|
||||||
/** Include the user password hash */
|
|
||||||
"includePasswordHash?": "boolean",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FullExportBody = typeof fullExportBodySchema.infer;
|
export type FullExportBody = typeof fullExportBodySchema.infer;
|
||||||
export type SecretsMode = typeof secretsModeSchema.infer;
|
|
||||||
|
|
||||||
const exportResponseSchema = type({
|
const exportResponseSchema = type({
|
||||||
version: "number",
|
version: "number",
|
||||||
"exportedAt?": "string",
|
exportedAt: "string?",
|
||||||
"recoveryKey?": "string",
|
recoveryKey: "string?",
|
||||||
"volumes?": "unknown[]",
|
volumes: "unknown[]?",
|
||||||
"repositories?": "unknown[]",
|
repositories: "unknown[]?",
|
||||||
"backupSchedules?": "unknown[]",
|
backupSchedules: "unknown[]?",
|
||||||
"notificationDestinations?": "unknown[]",
|
notificationDestinations: "unknown[]?",
|
||||||
"users?": type({
|
users: type({
|
||||||
"id?": "number",
|
id: "number?",
|
||||||
username: "string",
|
username: "string",
|
||||||
"passwordHash?": "string",
|
passwordHash: "string?",
|
||||||
"createdAt?": "number",
|
createdAt: "number?",
|
||||||
"updatedAt?": "number",
|
updatedAt: "number?",
|
||||||
"hasDownloadedResticPassword?": "boolean",
|
hasDownloadedResticPassword: "boolean?",
|
||||||
}).array(),
|
})
|
||||||
|
.array()
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorResponseSchema = type({
|
const errorResponseSchema = type({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue