Compare commits
67 commits
main
...
tvarohohla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6bbcfd74 | ||
|
|
1d49f59ebc | ||
|
|
99ccfa4305 | ||
|
|
64b7b93831 | ||
|
|
3da653d5ad | ||
|
|
3f550678db | ||
|
|
081a10afeb | ||
|
|
20c3b8156d | ||
|
|
b84ae02ec6 | ||
|
|
378878f439 | ||
|
|
0859fba431 | ||
|
|
4090d5caa6 | ||
|
|
ce714f7bfc | ||
|
|
e15fff2c23 | ||
|
|
375cd10fea | ||
|
|
62df1b1241 | ||
|
|
dc5c88efcd | ||
|
|
630b34ee10 | ||
|
|
535e5c7e7d | ||
|
|
1421085660 | ||
|
|
83dfbf3212 | ||
|
|
512bee535a | ||
|
|
20861cda70 | ||
|
|
34b9e4f6fb | ||
|
|
1beaf406cc | ||
|
|
6ad6044a76 | ||
|
|
764b64cc25 | ||
|
|
b55bebf8f4 | ||
|
|
ded9d935ce | ||
|
|
66f8ff0e50 | ||
|
|
63f67b3b30 | ||
|
|
bf36f9aa56 | ||
|
|
692dd5aa21 | ||
|
|
a7e36e98d7 | ||
|
|
9f1a7694a3 | ||
|
|
2dfa15a83a | ||
|
|
13883f3053 | ||
|
|
94712bb7b8 | ||
|
|
fd4f031963 | ||
|
|
f1e59e5931 | ||
|
|
2b6cd7af2c | ||
|
|
7dab73e9bd | ||
|
|
eaca5abde5 | ||
|
|
7f966b504b | ||
|
|
709a08cfe8 | ||
|
|
c736657ffe | ||
|
|
30047cbcf0 | ||
|
|
ced7a08672 | ||
|
|
5093699568 | ||
|
|
c65d84ee9a | ||
|
|
11646b6187 | ||
|
|
a9ac6eec20 | ||
|
|
2306df316e | ||
|
|
07f7c5e025 | ||
|
|
4536f8a613 | ||
|
|
f452d72050 | ||
|
|
82f632f63a | ||
|
|
95342f6ec9 | ||
|
|
1b874727e3 | ||
|
|
c5c5da78bd | ||
|
|
0d339d973f | ||
|
|
d72975e9e2 | ||
|
|
8e7e1694d5 | ||
|
|
55e8f2e2d3 | ||
|
|
94342cb48d | ||
|
|
3df892455f | ||
|
|
2be65f6069 |
14 changed files with 493 additions and 26 deletions
|
|
@ -50,7 +50,7 @@ services:
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris # Set your timezone here
|
||||
- TZ=Europe/Paris # Set your timezone here
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
|
@ -83,7 +83,7 @@ services:
|
|||
ports:
|
||||
- "4096:4096"
|
||||
environment:
|
||||
- TZ=Europe/Paris # Set your timezone here
|
||||
- TZ=Europe/Paris # Set your timezone here
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
|
@ -91,6 +91,7 @@ services:
|
|||
```
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ Improved security by reducing container capabilities
|
||||
- ✅ Support for local directories
|
||||
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
|
||||
|
|
|
|||
|
|
@ -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, 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
|
||||
|
|
@ -995,3 +995,20 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
|
|||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export full configuration including all volumes, repositories, backup schedules, and notifications
|
||||
*/
|
||||
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportFullConfig({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, 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> & {
|
||||
/**
|
||||
|
|
@ -425,3 +425,15 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
|
|||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Export full configuration including all volumes, repositories, backup schedules, and notifications
|
||||
*/
|
||||
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => (options?.client ?? client).post<ExportFullConfigResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/system/export',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3337,3 +3337,37 @@ export type DownloadResticPasswordResponses = {
|
|||
};
|
||||
|
||||
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||
|
||||
export type ExportFullConfigData = {
|
||||
body?: {
|
||||
password: string;
|
||||
includeMetadata?: boolean;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/system/export';
|
||||
};
|
||||
|
||||
export type ExportFullConfigResponses = {
|
||||
/**
|
||||
* Full configuration export
|
||||
*/
|
||||
200: {
|
||||
version: number;
|
||||
backupSchedules?: Array<unknown>;
|
||||
exportedAt?: string;
|
||||
notificationDestinations?: Array<unknown>;
|
||||
recoveryKey?: string;
|
||||
repositories?: Array<unknown>;
|
||||
users?: Array<{
|
||||
username: string;
|
||||
createdAt?: number;
|
||||
hasDownloadedResticPassword?: boolean;
|
||||
id?: number;
|
||||
updatedAt?: number;
|
||||
}>;
|
||||
volumes?: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses];
|
||||
|
|
|
|||
125
app/client/components/export-dialog.tsx
Normal file
125
app/client/components/export-dialog.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-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";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { parseError } from "../lib/errors";
|
||||
import { downloadFile } from "../lib/utils";
|
||||
|
||||
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
|
||||
|
||||
export const ExportDialog = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const exportMutation = useMutation({
|
||||
...exportFullConfigMutation(),
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
exportMutation.mutate({
|
||||
body: {
|
||||
password,
|
||||
includeMetadata,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setPassword("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export configuration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleExport}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Full Configuration</DialogTitle>
|
||||
<DialogDescription>Export the complete Zerobyte configuration.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeMetadata"
|
||||
checked={includeMetadata}
|
||||
onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeMetadata" className="cursor-pointer">
|
||||
Include metadata
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include timestamps and runtime state (status, health checks, last backup info).
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<Label htmlFor="export-password">Your Password</Label>
|
||||
<Input
|
||||
id="export-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password to export"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Password is required to verify your identity before exporting configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={exportMutation.isPending}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User, X } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -23,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" }],
|
||||
|
|
@ -78,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("");
|
||||
|
|
@ -265,6 +258,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
Export Configuration
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Export your Zerobyte configuration for backup or migration</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
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 />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,19 @@ export class AuthService {
|
|||
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password for a user (for re-authentication purposes)
|
||||
*/
|
||||
async verifyPassword(userId: number, password: string): Promise<boolean> {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Bun.password.verify(password, user.passwordHash);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe("system security", () => {
|
|||
const endpoints: { method: string; path: string }[] = [
|
||||
{ method: "GET", path: "/api/v1/system/info" },
|
||||
{ method: "POST", path: "/api/v1/system/restic-password" },
|
||||
{ method: "POST", path: "/api/v1/system/export" },
|
||||
];
|
||||
|
||||
for (const { method, path } of endpoints) {
|
||||
|
|
@ -86,4 +87,60 @@ describe("system security", () => {
|
|||
expect(body.message).toBe("Incorrect password");
|
||||
});
|
||||
});
|
||||
|
||||
test("should return 400 for invalid payload on full export", async () => {
|
||||
const { sessionId } = await createTestSession();
|
||||
const res = await app.request("/api/v1/system/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `session_id=${sessionId}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("should return 401 for incorrect password on full export", async () => {
|
||||
const { sessionId } = await createTestSession();
|
||||
const res = await app.request("/api/v1/system/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `session_id=${sessionId}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
includeMetadata: true,
|
||||
password: "wrong-password",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Incorrect password");
|
||||
});
|
||||
|
||||
test("full export never exposes password hashes", async () => {
|
||||
const { sessionId } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/system/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `session_id=${sessionId}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
includeMetadata: true,
|
||||
password: "testpassword",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { users?: Array<Record<string, unknown>> };
|
||||
expect(Array.isArray(body.users)).toBe(true);
|
||||
for (const user of body.users ?? []) {
|
||||
expect(user.passwordHash).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { validator } from "hono-openapi";
|
|||
import {
|
||||
downloadResticPasswordBodySchema,
|
||||
downloadResticPasswordDto,
|
||||
fullExportBodySchema,
|
||||
fullExportDto,
|
||||
getUpdatesDto,
|
||||
systemInfoDto,
|
||||
type SystemInfoDto,
|
||||
|
|
@ -61,4 +63,24 @@ export const systemController = new Hono()
|
|||
return c.json({ message: "Failed to read Restic password file" }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
||||
const user = c.get("user");
|
||||
const { password, ...body } = c.req.valid("json");
|
||||
|
||||
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
|
||||
|
||||
if (!dbUser) {
|
||||
return c.json({ message: "User not found" }, 401);
|
||||
}
|
||||
|
||||
const isValid = await Bun.password.verify(password, dbUser.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ message: "Incorrect password" }, 401);
|
||||
}
|
||||
|
||||
const res = await systemService.exportConfig(body);
|
||||
|
||||
return c.json(res);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,3 +79,47 @@ export const downloadResticPasswordDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const fullExportBodySchema = type({
|
||||
includeMetadata: "boolean = false",
|
||||
password: "string",
|
||||
});
|
||||
|
||||
export type FullExportBody = typeof fullExportBodySchema.infer;
|
||||
|
||||
const exportResponseSchema = type({
|
||||
version: "number",
|
||||
exportedAt: "string?",
|
||||
recoveryKey: "string?",
|
||||
volumes: "unknown[]?",
|
||||
repositories: "unknown[]?",
|
||||
backupSchedules: "unknown[]?",
|
||||
notificationDestinations: "unknown[]?",
|
||||
users: type({
|
||||
id: "number?",
|
||||
username: "string",
|
||||
createdAt: "number?",
|
||||
updatedAt: "number?",
|
||||
hasDownloadedResticPassword: "boolean?",
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ExportFullConfigResponse = typeof exportResponseSchema.infer;
|
||||
|
||||
export const fullExportDto = describeRoute({
|
||||
description: "Export full configuration including all volumes, repositories, backup schedules, and notifications",
|
||||
operationId: "exportFullConfig",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Full configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(exportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@ import type { UpdateInfoDto } from "./system.dto";
|
|||
import semver from "semver";
|
||||
import { cache } from "../../utils/cache";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { db } from "~/server/db/db";
|
||||
import {
|
||||
backupScheduleMirrorsTable,
|
||||
backupScheduleNotificationsTable,
|
||||
backupSchedulesTable,
|
||||
notificationDestinationsTable,
|
||||
repositoriesTable,
|
||||
usersTable,
|
||||
volumesTable,
|
||||
type BackupScheduleMirror,
|
||||
type BackupScheduleNotification,
|
||||
type BackupSchedule,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
type ExportParams = {
|
||||
includeMetadata: boolean;
|
||||
};
|
||||
|
||||
const CACHE_TTL = 60 * 60;
|
||||
|
||||
|
|
@ -90,7 +107,113 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
|
|||
}
|
||||
};
|
||||
|
||||
const METADATA_KEYS = {
|
||||
timestamps: [
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"lastBackupAt",
|
||||
"nextBackupAt",
|
||||
"lastHealthCheck",
|
||||
"lastChecked",
|
||||
"lastCopyAt",
|
||||
],
|
||||
runtimeState: [
|
||||
"status",
|
||||
"lastError",
|
||||
"lastBackupStatus",
|
||||
"lastBackupError",
|
||||
"hasDownloadedResticPassword",
|
||||
"lastCopyStatus",
|
||||
"lastCopyError",
|
||||
"sortOrder",
|
||||
],
|
||||
};
|
||||
|
||||
const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
|
||||
|
||||
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
|
||||
if (includeMetadata) {
|
||||
return obj;
|
||||
}
|
||||
const result = { ...obj };
|
||||
for (const key of ALL_METADATA_KEYS) {
|
||||
delete result[key as keyof T];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function exportEntity(entity: Record<string, unknown>, params: ExportParams) {
|
||||
return filterMetadataOut(entity, params.includeMetadata);
|
||||
}
|
||||
|
||||
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: ExportParams) {
|
||||
return Promise.all(entities.map((e) => exportEntity(e, params)));
|
||||
}
|
||||
|
||||
const transformBackupSchedules = (
|
||||
schedules: BackupSchedule[],
|
||||
scheduleNotifications: BackupScheduleNotification[],
|
||||
scheduleMirrors: BackupScheduleMirror[],
|
||||
params: ExportParams,
|
||||
) => {
|
||||
return schedules.map((schedule) => {
|
||||
const assignments = scheduleNotifications
|
||||
.filter((sn) => sn.scheduleId === schedule.id)
|
||||
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
|
||||
|
||||
const mirrors = scheduleMirrors
|
||||
.filter((sm) => sm.scheduleId === schedule.id)
|
||||
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
|
||||
|
||||
return {
|
||||
...filterMetadataOut(schedule, params.includeMetadata),
|
||||
notifications: assignments,
|
||||
mirrors,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const exportConfig = async (params: ExportParams) => {
|
||||
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
|
||||
await Promise.all([
|
||||
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 backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params);
|
||||
|
||||
const [exportVolumes, exportRepositories, exportNotifications, exportedUsersWithHash] = await Promise.all([
|
||||
exportEntities(volumes, params) as Promise<typeof volumes>,
|
||||
exportEntities(repositories, params) as Promise<typeof repositories>,
|
||||
exportEntities(notifications, params) as Promise<typeof notifications>,
|
||||
exportEntities(users, params) as Promise<typeof users>,
|
||||
]);
|
||||
|
||||
const exportUsers = exportedUsersWithHash.map((user) => {
|
||||
const sanitizedUser = { ...user } as Record<string, unknown>;
|
||||
delete sanitizedUser.passwordHash;
|
||||
sanitizedUser.password = `\${USER_${user.username.toUpperCase()}_PASSWORD}`;
|
||||
return sanitizedUser;
|
||||
});
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
volumes: exportVolumes,
|
||||
repositories: exportRepositories,
|
||||
backupSchedules,
|
||||
notificationDestinations: exportNotifications,
|
||||
users: exportUsers,
|
||||
};
|
||||
};
|
||||
|
||||
export const systemService = {
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
exportConfig,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -186,4 +186,6 @@ const sealSecret = async (value: string): Promise<string> => {
|
|||
export const cryptoUtils = {
|
||||
resolveSecret,
|
||||
sealSecret,
|
||||
isEncrypted,
|
||||
decrypt,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue