diff --git a/README.md b/README.md index 4510932f..3022566f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 108068f5..dbf12e51 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await exportFullConfig({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 6760a83b..a950872d 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -425,3 +425,15 @@ export const downloadResticPassword = (opt ...options?.headers } }); + +/** + * Export full configuration including all volumes, repositories, backup schedules, and notifications + */ +export const exportFullConfig = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/system/export', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 5c8f13e5..21bd6c5e 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; + exportedAt?: string; + notificationDestinations?: Array; + recoveryKey?: string; + repositories?: Array; + users?: Array<{ + username: string; + createdAt?: number; + hasDownloadedResticPassword?: boolean; + id?: number; + updatedAt?: number; + }>; + volumes?: Array; + }; +}; + +export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses]; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx new file mode 100644 index 00000000..32c2b4ea --- /dev/null +++ b/app/client/components/export-dialog.tsx @@ -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 ( + + + + + +
+ + Export Full Configuration + Export the complete Zerobyte configuration. + + +
+
+ setIncludeMetadata(checked === true)} + /> + +
+

+ Include timestamps and runtime state (status, health checks, last backup info). +

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

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

+
+
+ + + + + +
+
+
+ ); +}; diff --git a/app/client/lib/utils.ts b/app/client/lib/utils.ts index 211eb300..79767062 100644 --- a/app/client/lib/utils.ts +++ b/app/client/lib/utils.ts @@ -28,3 +28,20 @@ export function slugify(input: string): string { .replace(/[_]{2,}/g, "_") .trim(); } + +type DownloadFileMimeType = "text/plain" | "application/json"; +export const downloadFile = (data: unknown, filename: string, mimeType: DownloadFileMimeType = "text/plain") => { + const content = mimeType === "application/json" && typeof data !== "string" ? JSON.stringify(data, null, 2) : data; + + const blob = new Blob([content as BlobPart], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 5235702a..05c59175 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -11,6 +11,7 @@ import { Label } from "~/client/components/ui/label"; import { authMiddleware } from "~/middleware/auth"; import type { Route } from "./+types/download-recovery-key"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { downloadFile } from "~/client/lib/utils"; export const clientMiddleware = [authMiddleware]; @@ -31,16 +32,7 @@ export default function DownloadRecoveryKeyPage() { const downloadResticPassword = useMutation({ ...downloadResticPasswordMutation(), onSuccess: (data) => { - const blob = new Blob([data], { type: "text/plain" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "restic.pass"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - + downloadFile(data, "restic.pass"); toast.success("Recovery key downloaded successfully!"); navigate("/volumes", { replace: true }); }, diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index aca29aaf..f8b36ede 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -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) { + +
+ + + Export Configuration + + Export your Zerobyte configuration for backup or migration +
+ +

+ Export all your volumes, repositories, backup schedules, and notification settings to a JSON file. This can be + used to restore your configuration on a new instance or as a backup of your settings. +

+ +
); } diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 4a97c0df..47f91e3d 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -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 { + 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(); diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index 5ce3d51e..60250ac3 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -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> }; + expect(Array.isArray(body.users)).toBe(true); + for (const user of body.users ?? []) { + expect(user.passwordHash).toBeUndefined(); + } + }); }); diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index 7e5aec7d..ab5b0273 100644 --- a/app/server/modules/system/system.controller.ts +++ b/app/server/modules/system/system.controller.ts @@ -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); + }); diff --git a/app/server/modules/system/system.dto.ts b/app/server/modules/system/system.dto.ts index 3eccafd8..4732695e 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index a89b4c1f..fc131d1e 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -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 => { } }; +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>(obj: T, includeMetadata: boolean): Partial { + 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, params: ExportParams) { + return filterMetadataOut(entity, params.includeMetadata); +} + +async function exportEntities>(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, + exportEntities(repositories, params) as Promise, + exportEntities(notifications, params) as Promise, + exportEntities(users, params) as Promise, + ]); + + const exportUsers = exportedUsersWithHash.map((user) => { + const sanitizedUser = { ...user } as Record; + 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, }; diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index 34c3af98..d3105e97 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -186,4 +186,6 @@ const sealSecret = async (value: string): Promise => { export const cryptoUtils = { resolveSecret, sealSecret, + isEncrypted, + decrypt, };