From 2be65f606928f0fa9e99f2a237c7f830011ab616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 10:59:30 +0100 Subject: [PATCH 01/49] config export feature --- README.md | 20 + app/client/components/export-dialog.tsx | 330 +++++++++++++++++ .../backups/components/schedule-summary.tsx | 2 + app/client/modules/backups/routes/backups.tsx | 6 + .../routes/notification-details.tsx | 5 +- .../notifications/routes/notifications.tsx | 12 +- .../repositories/routes/repositories.tsx | 12 +- .../routes/repository-details.tsx | 5 +- .../modules/settings/routes/settings.tsx | 4 + .../modules/volumes/routes/volume-details.tsx | 4 + app/client/modules/volumes/routes/volumes.tsx | 12 +- app/server/index.ts | 4 +- .../lifecycle/config-export.controller.ts | 346 ++++++++++++++++++ app/server/utils/crypto.ts | 5 + 14 files changed, 752 insertions(+), 15 deletions(-) create mode 100644 app/client/components/export-dialog.tsx create mode 100644 app/server/modules/lifecycle/config-export.controller.ts diff --git a/README.md b/README.md index f2f3652e..ff7adb60 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,26 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true) +## Exporting configuration + +Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export: + +- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations +- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules + +To export, click the "Export" button on any list page or detail page. A dialog will appear with options to: + +- **Include database IDs** - Useful for debugging or when you need to reference internal identifiers +- **Include timestamps** - Include createdAt/updatedAt fields in the export +- **Secrets handling** (for repositories and notifications): + - **Exclude** - Remove sensitive fields like passwords and API keys + - **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import) + - **Decrypt** - Export secrets as plaintext (use with caution) +- **Include recovery key** (full export only) - Include the master encryption key for all repositories +- **Include password hash** (full export only) - Include the hashed admin password for seamless migration + +Exports are downloaded as JSON files that can be used for reference or future import functionality. + ## Propagating mounts to host Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services. diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx new file mode 100644 index 00000000..902e112b --- /dev/null +++ b/app/client/components/export-dialog.tsx @@ -0,0 +1,330 @@ +import { Download } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +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 { Label } from "~/client/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/client/components/ui/select"; + +export type SecretsMode = "exclude" | "encrypted" | "cleartext"; + +export type ExportOptions = { + includeIds?: boolean; + includeTimestamps?: boolean; + includeRecoveryKey?: boolean; + includePasswordHash?: boolean; + secretsMode?: SecretsMode; + name?: string; + id?: string | number; +}; + +function downloadAsJson(data: unknown, filename: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise { + const params = new URLSearchParams(); + if (options.includeIds === false) params.set("includeIds", "false"); + if (options.includeTimestamps === false) params.set("includeTimestamps", "false"); + if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true"); + if (options.includePasswordHash === true) params.set("includePasswordHash", "true"); + if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode); + if (options.id !== undefined) params.set("id", String(options.id)); + if (options.name) params.set("name", options.name); + + const url = params.toString() ? `${endpoint}?${params}` : endpoint; + const res = await fetch(url, { credentials: "include" }); + + if (!res.ok) { + throw new Error(`Export failed: ${res.statusText}`); + } + + const data = await res.json(); + downloadAsJson(data, filename); +} + +export type ExportEntityType = "volumes" | "repositories" | "notifications" | "backups" | "full"; + +type ExportConfig = { + endpoint: string; + label: string; + labelPlural: string; + getFilename: (options: ExportOptions) => string; +}; + +const exportConfigs: Record = { + volumes: { + endpoint: "/api/v1/config/export/volumes", + label: "Volume", + labelPlural: "Volumes", + getFilename: (opts) => { + const identifier = opts.id ?? opts.name; + return identifier ? `volume-${identifier}-config` : "volumes-config"; + }, + }, + repositories: { + endpoint: "/api/v1/config/export/repositories", + label: "Repository", + labelPlural: "Repositories", + getFilename: (opts) => { + const identifier = opts.id ?? opts.name; + return identifier ? `repository-${identifier}-config` : "repositories-config"; + }, + }, + notifications: { + endpoint: "/api/v1/config/export/notifications", + label: "Notification", + labelPlural: "Notifications", + getFilename: (opts) => { + const identifier = opts.id ?? opts.name; + return identifier ? `notification-${identifier}-config` : "notifications-config"; + }, + }, + backups: { + endpoint: "/api/v1/config/export/backups", + label: "Backup Schedule", + labelPlural: "Backup Schedules", + getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"), + }, + full: { + endpoint: "/api/v1/config/export", + label: "Full Config", + labelPlural: "Full Config", + getFilename: () => "zerobyte-full-config", + }, +}; + +export async function exportConfig( + entityType: ExportEntityType, + options: ExportOptions = {} +): Promise { + const config = exportConfigs[entityType]; + const filename = config.getFilename(options); + await exportFromApi(config.endpoint, filename, options); +} + +type ExportDialogProps = { + entityType: ExportEntityType; + name?: string; + id?: string | number; + trigger?: React.ReactNode; + variant?: "default" | "outline" | "ghost" | "card"; + size?: "default" | "sm" | "lg" | "icon"; + triggerLabel?: string; + showIcon?: boolean; +}; + +export function ExportDialog({ + entityType, + name, + id, + trigger, + variant = "outline", + size = "default", + triggerLabel, + showIcon = true, +}: ExportDialogProps) { + const [open, setOpen] = useState(false); + const [includeIds, setIncludeIds] = useState(true); + const [includeTimestamps, setIncludeTimestamps] = useState(true); + const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); + const [includePasswordHash, setIncludePasswordHash] = useState(false); + const [secretsMode, setSecretsMode] = useState("exclude"); + const [isExporting, setIsExporting] = useState(false); + + const config = exportConfigs[entityType]; + const isSingleItem = !!(name || id); + const isFullExport = entityType === "full"; + // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR + const hasSecrets = entityType !== "backups" && entityType !== "volumes"; + const entityLabel = isSingleItem ? config.label : config.labelPlural; + + const handleExport = async () => { + setIsExporting(true); + try { + await exportConfig(entityType, { + includeIds, + includeTimestamps, + includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined, + includePasswordHash: isFullExport ? includePasswordHash : undefined, + secretsMode: hasSecrets ? secretsMode : undefined, + name, + id, + }); + toast.success(`${entityLabel} exported successfully`); + setOpen(false); + } catch (err) { + toast.error("Export failed", { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsExporting(false); + } + }; + + const defaultTrigger = + variant === "card" ? ( +
+ + + {triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`} + +
+ ) : ( + + ); + + return ( + + {trigger ?? defaultTrigger} + + + Export {entityLabel} + + {isSingleItem + ? `Export the configuration for this ${config.label.toLowerCase()}.` + : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + + + +
+
+ setIncludeIds(checked === true)} + /> + +
+

+ Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. +

+ +
+ setIncludeTimestamps(checked === true)} + /> + +
+

+ Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. +

+ + {hasSecrets && ( + <> +
+ + +
+

+ {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ + )} + + {isFullExport && ( + <> +
+ setIncludeRecoveryKey(checked === true)} + /> + +
+

+ ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. +

+ +
+ setIncludePasswordHash(checked === true)} + /> + +
+

+ Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). +

+ + )} +
+ + + + + +
+
+ ); +} + +export function ExportCard({ entityType, ...props }: Omit) { + const config = exportConfigs[entityType]; + + return ( + + ); +} diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 4422c775..c8b5187b 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -1,4 +1,5 @@ import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useMemo, useState } from "react"; import { OnOff } from "~/client/components/onoff"; import { Button } from "~/client/components/ui/button"; @@ -125,6 +126,7 @@ export const ScheduleSummary = (props: Props) => { Edit schedule + + diff --git a/app/client/modules/notifications/routes/notifications.tsx b/app/client/modules/notifications/routes/notifications.tsx index 4c3d4f97..8d7b9ea4 100644 --- a/app/client/modules/notifications/routes/notifications.tsx +++ b/app/client/modules/notifications/routes/notifications.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Bell, Plus, RotateCcw } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { EmptyState } from "~/client/components/empty-state"; @@ -122,10 +123,13 @@ export default function Notifications({ loaderData }: Route.ComponentProps) { )} - +
+ + +
diff --git a/app/client/modules/repositories/routes/repositories.tsx b/app/client/modules/repositories/routes/repositories.tsx index 4e65bacd..40fc4d93 100644 --- a/app/client/modules/repositories/routes/repositories.tsx +++ b/app/client/modules/repositories/routes/repositories.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Database, Plus, RotateCcw } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { listRepositories } from "~/client/api-client/sdk.gen"; @@ -119,10 +120,13 @@ export default function Repositories({ loaderData }: Route.ComponentProps) { )} - +
+ + +
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index 394b7d7b..0ddf3e7a 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -25,7 +25,8 @@ import { cn } from "~/client/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; -import { Loader2 } from "lucide-react"; +import { Loader2, Trash2 } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -157,7 +158,9 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro "Run Doctor" )} + diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index a5397124..511793d9 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 } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; @@ -143,6 +144,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) { Your account details +
+ +
diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index 3ee690a3..b16815f5 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -25,6 +25,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/ import { useSystemInfo } from "~/client/hooks/use-system-info"; import { getVolume } from "~/client/api-client"; import type { VolumeStatus } from "~/client/lib/types"; +import { Trash2 } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { deleteVolumeMutation, getVolumeOptions, @@ -160,7 +162,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) { > Unmount +
diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index fc8bbda4..14da0b13 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { HardDrive, Plus, RotateCcw } from "lucide-react"; +import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { EmptyState } from "~/client/components/empty-state"; @@ -129,10 +130,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { )} - +
+ + +
diff --git a/app/server/index.ts b/app/server/index.ts index 09e8045b..c7dec603 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -22,6 +22,7 @@ import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants"; import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; +import { configExportController } from "./modules/lifecycle/config-export.controller"; export const generalDescriptor = (app: Hono) => openAPIRouteHandler(app, { @@ -51,7 +52,8 @@ const app = new Hono() .route("/api/v1/backups", backupScheduleController.use(requireAuth)) .route("/api/v1/notifications", notificationsController.use(requireAuth)) .route("/api/v1/system", systemController.use(requireAuth)) - .route("/api/v1/events", eventsController.use(requireAuth)); + .route("/api/v1/events", eventsController.use(requireAuth)) + .route("/api/v1/config", configExportController.use(requireAuth)); app.get("/api/v1/openapi.json", generalDescriptor(app)); app.get("/api/v1/docs", scalarDescriptor); diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts new file mode 100644 index 00000000..006a0327 --- /dev/null +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -0,0 +1,346 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { eq } from "drizzle-orm"; +import { + backupSchedulesTable, + notificationDestinationsTable, + repositoriesTable, + backupScheduleNotificationsTable, + usersTable, + volumesTable, +} from "../../db/schema"; +import { db } from "../../db/db"; +import { logger } from "../../utils/logger"; +import { RESTIC_PASS_FILE } from "../../core/constants"; +import { cryptoUtils } from "../../utils/crypto"; + +// ============================================================================ +// Types +// ============================================================================ + +type SecretsMode = "exclude" | "encrypted" | "cleartext"; + +type ExportParams = { + includeIds: boolean; + includeTimestamps: boolean; + secretsMode: SecretsMode; + excludeKeys: string[]; +}; + +type FilterOptions = { id?: string; name?: string }; + +type FetchResult = { data: T[] } | { error: string; status: 400 | 404 }; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function omitKeys>(obj: T, keys: string[]): Partial { + const result = { ...obj }; + for (const key of keys) { + delete result[key as keyof T]; + } + return result; +} + +function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean): string[] { + const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"]; + const timestampKeys = ["createdAt", "updatedAt"]; + return [ + ...(includeIds ? [] : idKeys), + ...(includeTimestamps ? [] : timestampKeys), + ]; +} + +/** Parse common export query parameters from request */ +function parseExportParams(c: Context): ExportParams { + const includeIds = c.req.query("includeIds") !== "false"; + const includeTimestamps = c.req.query("includeTimestamps") !== "false"; + const secretsMode = (c.req.query("secretsMode") as SecretsMode) || "exclude"; + const excludeKeys = getExcludeKeys(includeIds, includeTimestamps); + return { includeIds, includeTimestamps, secretsMode, excludeKeys }; +} + +/** Get filter options from request query params */ +function getFilterOptions(c: Context): FilterOptions { + return { id: c.req.query("id"), name: c.req.query("name") }; +} + +/** + * Process secrets in an object based on the secrets mode. + * Automatically detects encrypted fields using cryptoUtils.isEncrypted. + */ +async function processSecrets( + obj: Record, + secretsMode: SecretsMode +): Promise> { + if (secretsMode === "encrypted") { + return obj; + } + + const result = { ...obj }; + + for (const [key, value] of Object.entries(result)) { + if (typeof value === "string" && cryptoUtils.isEncrypted(value)) { + if (secretsMode === "exclude") { + delete result[key]; + } else if (secretsMode === "cleartext") { + try { + result[key] = await cryptoUtils.decrypt(value); + } catch { + delete result[key]; + } + } + } else if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = await processSecrets(value as Record, secretsMode); + } + } + + return result; +} + +/** Clean and process an entity for export */ +async function exportEntity( + entity: Record, + params: ExportParams +): Promise> { + const cleaned = omitKeys(entity, params.excludeKeys); + return processSecrets(cleaned, params.secretsMode); +} + +/** Export multiple entities */ +async function exportEntities>( + entities: T[], + params: ExportParams +): Promise[]> { + return Promise.all(entities.map((e) => exportEntity(e as Record, params))); +} + +// ============================================================================ +// Data Fetchers with Filtering +// ============================================================================ + +async function fetchVolumes(filter: FilterOptions): Promise> { + if (filter.id) { + const id = Number.parseInt(filter.id, 10); + if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 }; + const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id)); + if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 }; + return { data: result }; + } + if (filter.name) { + const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name)); + if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 }; + return { data: result }; + } + return { data: await db.select().from(volumesTable) }; +} + +async function fetchRepositories(filter: FilterOptions): Promise> { + if (filter.id) { + const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id)); + if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 }; + return { data: result }; + } + if (filter.name) { + const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name)); + if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 }; + return { data: result }; + } + return { data: await db.select().from(repositoriesTable) }; +} + +async function fetchNotifications(filter: FilterOptions): Promise> { + if (filter.id) { + const id = Number.parseInt(filter.id, 10); + if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 }; + const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id)); + if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 }; + return { data: result }; + } + if (filter.name) { + const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name)); + if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 }; + return { data: result }; + } + return { data: await db.select().from(notificationDestinationsTable) }; +} + +async function fetchBackupSchedules(filter: { id?: string }): Promise> { + if (filter.id) { + const id = Number.parseInt(filter.id, 10); + if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 }; + const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id)); + if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 }; + return { data: result }; + } + return { data: await db.select().from(backupSchedulesTable) }; +} + +/** Transform backup schedules with resolved names and notifications */ +function transformBackupSchedules( + schedules: typeof backupSchedulesTable.$inferSelect[], + scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[], + volumeMap: Map, + repoMap: Map, + notificationMap: Map, + params: ExportParams +) { + return schedules.map((schedule) => { + const assignments = scheduleNotifications + .filter((sn) => sn.scheduleId === schedule.id) + .map((sn) => ({ + ...(params.includeIds ? { destinationId: sn.destinationId } : {}), + name: notificationMap.get(sn.destinationId) ?? null, + notifyOnStart: sn.notifyOnStart, + notifyOnSuccess: sn.notifyOnSuccess, + notifyOnFailure: sn.notifyOnFailure, + })); + + return { + ...omitKeys(schedule as Record, params.excludeKeys), + volume: volumeMap.get(schedule.volumeId) ?? null, + repository: repoMap.get(schedule.repositoryId) ?? null, + notifications: assignments, + }; + }); +} + +// ============================================================================ +// Controller +// ============================================================================ + +/** + * Config Export API + * + * Query parameters: + * - includeIds: "true" | "false" (default: "true") - Include database IDs + * - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt + * - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only) + * - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only) + * - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets + * - id: string (optional) - Filter by ID + * - name: string (optional) - Filter by name (not for backups) + */ +export const configExportController = new Hono() + .get("/export", async (c) => { + try { + const params = parseExportParams(c); + const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true"; + const includePasswordHash = c.req.query("includePasswordHash") === "true"; + + const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = 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(usersTable).limit(1), + ]); + + const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); + const repoMap = new Map(repositories.map((r) => [r.id, r.name])); + const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); + + const backupSchedules = transformBackupSchedules( + backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params + ); + + // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR + const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ + exportEntities(volumes, params), + exportEntities(repositories, params), + exportEntities(notifications, params), + ]); + + let recoveryKey: string | undefined; + if (includeRecoveryKey) { + try { + recoveryKey = await Bun.file(RESTIC_PASS_FILE).text(); + } catch { + logger.warn("Could not read recovery key file"); + } + } + + return c.json({ + version: 1, + exportedAt: new Date().toISOString(), + volumes: exportVolumes, + repositories: exportRepositories, + backupSchedules, + notificationDestinations: exportNotifications, + admin: admin ? { + username: admin.username, + ...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}), + ...(recoveryKey ? { recoveryKey } : {}), + } : null, + }); + } catch (err) { + logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); + return c.json({ error: "Failed to export config" }, 500); + } + }) + .get("/export/volumes", async (c) => { + try { + const params = parseExportParams(c); + const result = await fetchVolumes(getFilterOptions(c)); + if ("error" in result) return c.json({ error: result.error }, result.status); + // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR + return c.json({ volumes: await exportEntities(result.data, params) }); + } catch (err) { + logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); + return c.json({ error: "Failed to export volumes" }, 500); + } + }) + .get("/export/repositories", async (c) => { + try { + const params = parseExportParams(c); + const result = await fetchRepositories(getFilterOptions(c)); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ repositories: await exportEntities(result.data, params) }); + } catch (err) { + logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); + return c.json({ error: "Failed to export repositories" }, 500); + } + }) + .get("/export/notifications", async (c) => { + try { + const params = parseExportParams(c); + const result = await fetchNotifications(getFilterOptions(c)); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ notificationDestinations: await exportEntities(result.data, params) }); + } catch (err) { + logger.error(`Notifications export failed: ${err instanceof Error ? err.message : String(err)}`); + return c.json({ error: "Failed to export notifications" }, 500); + } + }) + .get("/export/backups", async (c) => { + try { + const params = parseExportParams(c); + + const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ + db.select().from(volumesTable), + db.select().from(repositoriesTable), + db.select().from(notificationDestinationsTable), + db.select().from(backupScheduleNotificationsTable), + ]); + + const result = await fetchBackupSchedules({ id: c.req.query("id") }); + if ("error" in result) return c.json({ error: result.error }, result.status); + + const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); + const repoMap = new Map(repositories.map((r) => [r.id, r.name])); + const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); + + const backupSchedules = transformBackupSchedules( + result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params + ); + + return c.json({ backupSchedules }); + } catch (err) { + logger.error(`Backups export failed: ${err instanceof Error ? err.message : String(err)}`); + return c.json({ error: "Failed to export backups" }, 500); + } + }); + + diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index 651bebe9..1ed05d2a 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -5,6 +5,10 @@ const algorithm = "aes-256-gcm" as const; const keyLength = 32; const encryptionPrefix = "encv1"; +const isEncrypted = (val?: string): boolean => { + return typeof val === "string" && val.startsWith(encryptionPrefix); +}; + /** * Given a string, encrypts it using a randomly generated salt */ @@ -58,4 +62,5 @@ const decrypt = async (encryptedData: string) => { export const cryptoUtils = { encrypt, decrypt, + isEncrypted, }; From 3df892455fc5a0f00c8f65903440544147bc0ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 11:29:39 +0100 Subject: [PATCH 02/49] optionaly exclude runtime info --- app/client/components/export-dialog.tsx | 18 ++++++++++++++++++ .../lifecycle/config-export.controller.ts | 15 +++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 902e112b..13de35a4 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -26,6 +26,7 @@ export type SecretsMode = "exclude" | "encrypted" | "cleartext"; export type ExportOptions = { includeIds?: boolean; includeTimestamps?: boolean; + includeRuntimeState?: boolean; includeRecoveryKey?: boolean; includePasswordHash?: boolean; secretsMode?: SecretsMode; @@ -49,6 +50,7 @@ async function exportFromApi(endpoint: string, filename: string, options: Export const params = new URLSearchParams(); if (options.includeIds === false) params.set("includeIds", "false"); if (options.includeTimestamps === false) params.set("includeTimestamps", "false"); + if (options.includeRuntimeState === true) params.set("includeRuntimeState", "true"); if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true"); if (options.includePasswordHash === true) params.set("includePasswordHash", "true"); if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode); @@ -150,6 +152,7 @@ export function ExportDialog({ const [open, setOpen] = useState(false); const [includeIds, setIncludeIds] = useState(true); const [includeTimestamps, setIncludeTimestamps] = useState(true); + const [includeRuntimeState, setIncludeRuntimeState] = useState(false); const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false); const [secretsMode, setSecretsMode] = useState("exclude"); @@ -168,6 +171,7 @@ export function ExportDialog({ await exportConfig(entityType, { includeIds, includeTimestamps, + includeRuntimeState, includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined, includePasswordHash: isFullExport ? includePasswordHash : undefined, secretsMode: hasSecrets ? secretsMode : undefined, @@ -242,6 +246,20 @@ export function ExportDialog({ Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.

+
+ setIncludeRuntimeState(checked === true)} + /> + +
+

+ Include current status, health checks, and last backup information. Usually not needed for migration. +

+ {hasSecrets && ( <>
diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 006a0327..16163494 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -43,10 +43,20 @@ function omitKeys>(obj: T, keys: string[]): Pa return result; } -function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean): string[] { +function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, includeRuntimeState: boolean): string[] { const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"]; const timestampKeys = ["createdAt", "updatedAt"]; + // Runtime state fields (status, health checks, last backup info, etc.) + const runtimeStateKeys = [ + // Volume state + "status", "lastError", "lastHealthCheck", + // Repository state + "lastChecked", + // Backup schedule state + "lastBackupAt", "lastBackupStatus", "lastBackupError", "nextBackupAt", + ]; return [ + ...(includeRuntimeState ? [] : runtimeStateKeys), ...(includeIds ? [] : idKeys), ...(includeTimestamps ? [] : timestampKeys), ]; @@ -56,8 +66,9 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean): string function parseExportParams(c: Context): ExportParams { const includeIds = c.req.query("includeIds") !== "false"; const includeTimestamps = c.req.query("includeTimestamps") !== "false"; + const includeRuntimeState = c.req.query("includeRuntimeState") === "true"; const secretsMode = (c.req.query("secretsMode") as SecretsMode) || "exclude"; - const excludeKeys = getExcludeKeys(includeIds, includeTimestamps); + const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeIds, includeTimestamps, secretsMode, excludeKeys }; } From 94342cb48d29bd5d1cf5c61552ef5230271a0d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 11:31:19 +0100 Subject: [PATCH 03/49] whole export backups tile clickable --- app/client/components/export-dialog.tsx | 2 +- app/client/modules/backups/routes/backups.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 13de35a4..937eab9b 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -191,7 +191,7 @@ export function ExportDialog({ const defaultTrigger = variant === "card" ? ( -
+
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`} diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 01d28852..7f131f04 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -121,7 +121,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) { - + From 55e8f2e2d3e35620019e14bf9e155802bbbd435f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 11:33:56 +0100 Subject: [PATCH 04/49] runtime state option in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ff7adb60..4d61740a 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ To export, click the "Export" button on any list page or detail page. A dialog w - **Include database IDs** - Useful for debugging or when you need to reference internal identifiers - **Include timestamps** - Include createdAt/updatedAt fields in the export +- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration) - **Secrets handling** (for repositories and notifications): - **Exclude** - Remove sensitive fields like passwords and API keys - **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import) From 8e7e1694d5e9fdeab07de28fc6ba1f96a6c5878c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 14:42:52 +0100 Subject: [PATCH 05/49] more verbose error handling --- app/client/components/export-dialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 937eab9b..f36314cc 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -61,7 +61,8 @@ async function exportFromApi(endpoint: string, filename: string, options: Export const res = await fetch(url, { credentials: "include" }); if (!res.ok) { - throw new Error(`Export failed: ${res.statusText}`); + const errorText = await res.text().catch(() => res.statusText); + throw new Error(errorText || `HTTP ${res.status}`); } const data = await res.json(); From d72975e9e2f1dcb3a988c49f8d71544216a51cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:41:33 +0100 Subject: [PATCH 06/49] reauthentication on secrets export --- README.md | 2 + app/client/components/export-dialog.tsx | 143 ++++++++++++++++++--- app/server/modules/auth/auth.controller.ts | 26 ++++ app/server/modules/auth/auth.dto.ts | 28 ++++ app/server/modules/auth/auth.service.ts | 13 ++ 5 files changed, 192 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4d61740a..93f4c129 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,8 @@ To export, click the "Export" button on any list page or detail page. A dialog w - **Include recovery key** (full export only) - Include the master encryption key for all repositories - **Include password hash** (full export only) - Include the hashed admin password for seamless migration +When exporting sensitive data (recovery key or decrypted secrets), you'll be prompted to re-enter your password as an additional security confirmation. This is a UI-level safeguard to prevent accidental exports of sensitive information. + Exports are downloaded as JSON files that can be used for reference or future import functionality. ## Propagating mounts to host diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index f36314cc..db2e7c0f 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -12,6 +12,7 @@ import { DialogTitle, DialogTrigger, } from "~/client/components/ui/dialog"; +import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; import { Select, @@ -21,6 +22,15 @@ import { SelectValue, } from "~/client/components/ui/select"; +async function verifyPassword(password: string): Promise { + const response = await fetch("/api/v1/auth/verify-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + return response.ok; +} + export type SecretsMode = "exclude" | "encrypted" | "cleartext"; export type ExportOptions = { @@ -158,6 +168,9 @@ export function ExportDialog({ const [includePasswordHash, setIncludePasswordHash] = useState(false); const [secretsMode, setSecretsMode] = useState("exclude"); const [isExporting, setIsExporting] = useState(false); + const [showPasswordStep, setShowPasswordStep] = useState(false); + const [password, setPassword] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); const config = exportConfigs[entityType]; const isSingleItem = !!(name || id); @@ -165,8 +178,9 @@ export function ExportDialog({ // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR const hasSecrets = entityType !== "backups" && entityType !== "volumes"; const entityLabel = isSingleItem ? config.label : config.labelPlural; + const requiresPassword = includeRecoveryKey || secretsMode === "cleartext"; - const handleExport = async () => { + const performExport = async () => { setIsExporting(true); try { await exportConfig(entityType, { @@ -181,6 +195,8 @@ export function ExportDialog({ }); toast.success(`${entityLabel} exported successfully`); setOpen(false); + setShowPasswordStep(false); + setPassword(""); } catch (err) { toast.error("Export failed", { description: err instanceof Error ? err.message : String(err), @@ -190,6 +206,45 @@ export function ExportDialog({ } }; + const handleExport = () => { + if (requiresPassword) { + setShowPasswordStep(true); + } else { + performExport(); + } + }; + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password) { + toast.error("Password is required"); + return; + } + + setIsVerifying(true); + try { + const isValid = await verifyPassword(password); + if (!isValid) { + toast.error("Incorrect password"); + return; + } + // Password verified, proceed with export + await performExport(); + } catch { + toast.error("Incorrect password"); + } finally { + setIsVerifying(false); + } + }; + + const handleDialogChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setShowPasswordStep(false); + setPassword(""); + } + }; + const defaultTrigger = variant === "card" ? (
@@ -206,19 +261,65 @@ export function ExportDialog({ ); return ( - + {trigger ?? defaultTrigger} - - Export {entityLabel} - - {isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} - - + {showPasswordStep ? ( +
+ + Confirm Export + + For security reasons, please enter your password to export + {includeRecoveryKey && secretsMode === "cleartext" + ? " the recovery key and decrypted secrets." + : includeRecoveryKey + ? " the recovery key." + : " decrypted secrets."} + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + + + + + ) : ( + <> + + Export {entityLabel} + + {isSingleItem + ? `Export the configuration for this ${config.label.toLowerCase()}.` + : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + + -
+
- - - - + + + + + + )}
); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index c78d2818..adf9c966 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -12,12 +12,15 @@ import { logoutDto, registerBodySchema, registerDto, + verifyPasswordBodySchema, + verifyPasswordDto, type ChangePasswordDto, type GetMeDto, type GetStatusDto, type LoginDto, type LogoutDto, type RegisterDto, + type VerifyPasswordDto, } from "./auth.dto"; import { authService } from "./auth.service"; import { toMessage } from "../../utils/errors"; @@ -138,4 +141,27 @@ export const authController = new Hono() } catch (error) { return c.json({ success: false, message: toMessage(error) }, 400); } + }) + .post("/verify-password", verifyPasswordDto, validator("json", verifyPasswordBodySchema), async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const body = c.req.valid("json"); + const isValid = await authService.verifyPassword(session.user.id, body.password); + + if (!isValid) { + return c.json({ success: false, message: "Incorrect password" }, 401); + } + + return c.json({ success: true, message: "Password verified" }); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index f305f9c1..846c204b 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -148,6 +148,34 @@ export const changePasswordDto = describeRoute({ export type ChangePasswordDto = typeof changePasswordResponseSchema.infer; +export const verifyPasswordBodySchema = type({ + password: "string>0", +}); + +const verifyPasswordResponseSchema = type({ + success: "boolean", + message: "string", +}); + +export const verifyPasswordDto = describeRoute({ + description: "Verify current user password for re-authentication", + operationId: "verifyPassword", + tags: ["Auth"], + responses: { + 200: { + description: "Password verification result", + content: { + "application/json": { + schema: resolver(verifyPasswordResponseSchema), + }, + }, + }, + }, +}); + +export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer; + export type LoginBody = typeof loginBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer; export type ChangePasswordBody = typeof changePasswordBodySchema.infer; +export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer; 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(); From 0d339d973f20756a352e2306a5a473adaf4f5b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:47:59 +0100 Subject: [PATCH 07/49] Deleted unrelated change TODO: Fix trash icon in separate PR Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/client/modules/repositories/routes/repository-details.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index 0ddf3e7a..c55960a7 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -160,7 +160,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
From c5c5da78bd159038d519da832284d692be77a76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:48:45 +0100 Subject: [PATCH 08/49] Delete unrelated change TODO: Fix Trash icon in different PR Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/client/modules/notifications/routes/notification-details.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index 3a1e3c50..7b8b9454 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -149,7 +149,6 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP variant="destructive" loading={deleteDestination.isPending} > - Delete
From 1b874727e339936b803ff33adbc2b585c6d549b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:53:12 +0100 Subject: [PATCH 09/49] Update app/server/modules/lifecycle/config-export.controller.ts More verbose error handling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/server/modules/lifecycle/config-export.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 16163494..ce0465c4 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -288,7 +288,7 @@ export const configExportController = new Hono() }); } catch (err) { logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: "Failed to export config" }, 500); + return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); } }) .get("/export/volumes", async (c) => { From 95342f6ec9c36ffca9b6b2477cba0095f49b21f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:55:09 +0100 Subject: [PATCH 10/49] Update app/server/modules/lifecycle/config-export.controller.ts More verbose error handling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/server/modules/lifecycle/config-export.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index ce0465c4..58c7864f 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -300,7 +300,7 @@ export const configExportController = new Hono() return c.json({ volumes: await exportEntities(result.data, params) }); } catch (err) { logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: "Failed to export volumes" }, 500); + return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) .get("/export/repositories", async (c) => { From 82f632f63a0063282142a5afc837cb75bbff8d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 15:58:07 +0100 Subject: [PATCH 11/49] Update app/client/components/export-dialog.tsx remove unused variable Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/client/components/export-dialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index db2e7c0f..ae8cfa23 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -439,7 +439,6 @@ export function ExportDialog({ } export function ExportCard({ entityType, ...props }: Omit) { - const config = exportConfigs[entityType]; return ( Date: Mon, 1 Dec 2025 16:11:12 +0100 Subject: [PATCH 12/49] Fix keyboard navigation in the export card Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/client/components/export-dialog.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index ae8cfa23..e732dd0e 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -247,12 +247,15 @@ export function ExportDialog({ const defaultTrigger = variant === "card" ? ( -
+
+ ) : ( - +
diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index 7b8b9454..89e053e9 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -143,7 +143,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP Test - + - + - + diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index b16815f5..1d46f0a3 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -162,7 +162,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) { > Unmount - +
From 7f966b504b6da4656c7ea93ea1cd91f1d808b86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:02:50 +0100 Subject: [PATCH 22/49] Explicit warning regarding volume secrets Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/server/modules/lifecycle/config-export.controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 23bf7382..e91d00cf 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -263,7 +263,9 @@ export const configExportController = new Hono() backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); - // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR + // WARNING: As of now, volume exports may include sensitive data (e.g., SMB/NFS credentials) in cleartext. + // This is a known security limitation. Handle exported configuration files with care. + // Future PRs will implement encryption for these secrets. const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), From eaca5abde5c94871c65b3d81713fb97df1bfae93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:07:24 +0100 Subject: [PATCH 23/49] Input validation and error handling for secretsMode param Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/server/modules/lifecycle/config-export.controller.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index e91d00cf..5344f0c8 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -67,7 +67,13 @@ function parseExportParams(c: Context): ExportParams { const includeIds = c.req.query("includeIds") !== "false"; const includeTimestamps = c.req.query("includeTimestamps") !== "false"; const includeRuntimeState = c.req.query("includeRuntimeState") === "true"; - const secretsMode = (c.req.query("secretsMode") as SecretsMode) || "exclude"; + const secretsModeRaw = c.req.query("secretsMode"); + const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"]; + const secretsMode: SecretsMode = secretsModeRaw + ? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode) + ? (secretsModeRaw as SecretsMode) + : (() => { throw new Error("Invalid secretsMode parameter"); })()) + : "exclude"; const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeIds, includeTimestamps, secretsMode, excludeKeys }; } From 7dab73e9bde3809dd69dbb7cd8fe8c4f76547367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:10:12 +0100 Subject: [PATCH 24/49] More verbose error logging for notification and backups export errors --- app/server/modules/lifecycle/config-export.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 5344f0c8..0b85b890 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -337,7 +337,7 @@ export const configExportController = new Hono() return c.json({ notificationDestinations: await exportEntities(result.data, params) }); } catch (err) { logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: "Failed to export notification destinations" }, 500); + return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) .get("/export/backup-schedules", async (c) => { @@ -365,7 +365,7 @@ export const configExportController = new Hono() return c.json({ backupSchedules }); } catch (err) { logger.error(`Backup schedules export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: "Failed to export backup schedules" }, 500); + return c.json({ error: `Failed to export backup schedules: ${err instanceof Error ? err.message : String(err)}` }, 500); } }); From 2b6cd7af2c0acc27fbbddb97c213f5319b468aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:15:18 +0100 Subject: [PATCH 25/49] possibly handle nested secrets in arrrays --- .../modules/lifecycle/config-export.controller.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 0b85b890..00c77577 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -109,7 +109,15 @@ async function processSecrets( delete result[key]; } } - } else if (value && typeof value === "object" && !Array.isArray(value)) { + } else if (Array.isArray(value)) { + result[key] = await Promise.all( + value.map(async (item) => + item && typeof item === "object" && !Array.isArray(item) + ? processSecrets(item as Record, secretsMode) + : item + ) + ); + } else if (value && typeof value === "object") { result[key] = await processSecrets(value as Record, secretsMode); } } From f1e59e593138acc3cd5545c887d355daa81fb94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:44:07 +0100 Subject: [PATCH 26/49] Custom message for Full export --- app/client/components/export-dialog.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 12682b36..4d44c023 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -330,9 +330,11 @@ export function ExportDialog({ Export {entityLabel} - {isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + {isFullExport + ? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications." + : isSingleItem + ? `Export the configuration for this ${config.label.toLowerCase()}.` + : `Export all ${config.labelPlural.toLowerCase()} configurations.`} From fd4f031963d0582fae473ea60c1c0e6e9e1f24dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 17:45:28 +0100 Subject: [PATCH 27/49] Better error logging for password verification failure --- app/client/components/export-dialog.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 4d44c023..6d5cf695 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -244,8 +244,10 @@ export function ExportDialog({ } // Password verified, proceed with export await performExport(); - } catch { - toast.error("Incorrect password"); + } catch (err) { + toast.error("Verification failed", { + description: err instanceof Error ? err.message : "Unable to verify password. Please check your connection and try again.", + }); } finally { setIsVerifying(false); } From 94712bb7b8c290646c666e48e59d35ab40519746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 1 Dec 2025 23:14:22 +0100 Subject: [PATCH 28/49] fix indentation --- app/client/components/export-dialog.tsx | 168 ++++++++++++------------ 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 6d5cf695..009e42b5 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -341,107 +341,107 @@ export function ExportDialog({
-
- setIncludeIds(checked === true)} - /> - -
-

- Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. -

- -
- setIncludeTimestamps(checked === true)} - /> - -
-

- Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. -

- -
- setIncludeRuntimeState(checked === true)} - /> - -
-

- Include current status, health checks, and last backup information. Usually not needed for migration. -

- - {hasSecrets && ( - <> -
- - +
+ setIncludeIds(checked === true)} + /> +
-

- {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} - {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} - {secretsMode === "cleartext" && ( - - ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! - - )} +

+ Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.

- - )} - {isFullExport && ( - <> -
+
setIncludeRecoveryKey(checked === true)} + id="includeTimestamps" + checked={includeTimestamps} + onCheckedChange={(checked) => setIncludeTimestamps(checked === true)} /> -

- ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. + Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.

setIncludePasswordHash(checked === true)} + id="includeRuntimeState" + checked={includeRuntimeState} + onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)} /> -

- Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). + Include current status, health checks, and last backup information. Usually not needed for migration.

- - )} -
+ + {hasSecrets && ( + <> +
+ + +
+

+ {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ + )} + + {isFullExport && ( + <> +
+ setIncludeRecoveryKey(checked === true)} + /> + +
+

+ ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. +

+ +
+ setIncludePasswordHash(checked === true)} + /> + +
+

+ Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). +

+ + )} +
- - - - ) : ( - <> - - Export {entityLabel} - - {isFullExport - ? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications." - : isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} - - - -
-
- setIncludeIds(checked === true)} - /> - -
-

- Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. -

- -
- setIncludeTimestamps(checked === true)} - /> - -
-

- Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. -

- -
- setIncludeRuntimeState(checked === true)} - /> - -
-

- Include current status, health checks, and last backup information. Usually not needed for migration. +

+ Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. +

+ +
+ setIncludeTimestamps(checked === true)} + /> + +
+

+ Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. +

+ +
+ setIncludeRuntimeState(checked === true)} + /> + +
+

+ Include current status, health checks, and last backup information. Usually not needed for migration. +

+ + {hasSecrets && ( + <> +
+ + +
+

+ {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ + )} + + {isFullExport && ( + <> +
+ setIncludeRecoveryKey(checked === true)} + /> + +
+

+ ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. +

+ +
+ setIncludePasswordHash(checked === true)} + /> + +
+

+ Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). +

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

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

- - {hasSecrets && ( - <> -
- - -
-

- {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} - {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} - {secretsMode === "cleartext" && ( - - ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! - - )} -

- - )} - - {isFullExport && ( - <> -
- setIncludeRecoveryKey(checked === true)} - /> - -
-

- ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. -

- -
- setIncludePasswordHash(checked === true)} - /> - -
-

- Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). -

- - )}
- - - - - - - )} +
+ + + + + + ); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index adf9c966..c78d2818 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -12,15 +12,12 @@ import { logoutDto, registerBodySchema, registerDto, - verifyPasswordBodySchema, - verifyPasswordDto, type ChangePasswordDto, type GetMeDto, type GetStatusDto, type LoginDto, type LogoutDto, type RegisterDto, - type VerifyPasswordDto, } from "./auth.dto"; import { authService } from "./auth.service"; import { toMessage } from "../../utils/errors"; @@ -141,27 +138,4 @@ export const authController = new Hono() } catch (error) { return c.json({ success: false, message: toMessage(error) }, 400); } - }) - .post("/verify-password", verifyPasswordDto, validator("json", verifyPasswordBodySchema), async (c) => { - const sessionId = getCookie(c, COOKIE_NAME); - - if (!sessionId) { - return c.json({ success: false, message: "Not authenticated" }, 401); - } - - const session = await authService.verifySession(sessionId); - - if (!session) { - deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); - return c.json({ success: false, message: "Not authenticated" }, 401); - } - - const body = c.req.valid("json"); - const isValid = await authService.verifyPassword(session.user.id, body.password); - - if (!isValid) { - return c.json({ success: false, message: "Incorrect password" }, 401); - } - - return c.json({ success: true, message: "Password verified" }); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index 846c204b..f305f9c1 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -148,34 +148,6 @@ export const changePasswordDto = describeRoute({ export type ChangePasswordDto = typeof changePasswordResponseSchema.infer; -export const verifyPasswordBodySchema = type({ - password: "string>0", -}); - -const verifyPasswordResponseSchema = type({ - success: "boolean", - message: "string", -}); - -export const verifyPasswordDto = describeRoute({ - description: "Verify current user password for re-authentication", - operationId: "verifyPassword", - tags: ["Auth"], - responses: { - 200: { - description: "Password verification result", - content: { - "application/json": { - schema: resolver(verifyPasswordResponseSchema), - }, - }, - }, - }, -}); - -export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer; - export type LoginBody = typeof loginBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer; export type ChangePasswordBody = typeof changePasswordBodySchema.infer; -export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer; diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index cd8e8c95..774b9206 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -1,24 +1,44 @@ +import { validator } from "hono-openapi"; + import { Hono } from "hono"; import type { Context } from "hono"; -import { eq } from "drizzle-orm"; +import { deleteCookie, getCookie } from "hono/cookie"; import { backupSchedulesTable, - notificationDestinationsTable, - repositoriesTable, backupScheduleNotificationsTable, usersTable, - volumesTable, } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { RESTIC_PASS_FILE } from "../../core/constants"; import { cryptoUtils } from "../../utils/crypto"; - -// ============================================================================ -// Types -// ============================================================================ - -type SecretsMode = "exclude" | "encrypted" | "cleartext"; +import { authService } from "../auth/auth.service"; +import { volumeService } from "../volumes/volume.service"; +import { repositoriesService } from "../repositories/repositories.service"; +import { notificationsService } from "../notifications/notifications.service"; +import { backupsService } from "../backups/backups.service"; +import { + fullExportBodySchema, + entityExportBodySchema, + backupScheduleExportBodySchema, + fullExportDto, + volumesExportDto, + repositoriesExportDto, + notificationsExportDto, + backupSchedulesExportDto, + type SecretsMode, + type FullExportBody, + type EntityExportBody, + type BackupScheduleExportBody, +} from "./config-export.dto"; + +const COOKIE_NAME = "session_id"; +const COOKIE_OPTIONS = { + httpOnly: true, + secure: false, + sameSite: "lax" as const, + path: "/", +}; type ExportParams = { includeIds: boolean; @@ -27,14 +47,6 @@ type ExportParams = { excludeKeys: string[]; }; -type FilterOptions = { id?: string; name?: string }; - -type FetchResult = { data: T[] } | { error: string; status: 400 | 404 }; - -// ============================================================================ -// Helper Functions -// ============================================================================ - function omitKeys>(obj: T, keys: string[]): Partial { const result = { ...obj }; for (const key of keys) { @@ -66,25 +78,46 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, include ]; } -/** Parse common export query parameters from request */ -function parseExportParams(c: Context): ExportParams { - const includeIds = c.req.query("includeIds") !== "false"; - const includeTimestamps = c.req.query("includeTimestamps") !== "false"; - const includeRuntimeState = c.req.query("includeRuntimeState") === "true"; - const secretsModeRaw = c.req.query("secretsMode"); - const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"]; - const secretsMode: SecretsMode = secretsModeRaw - ? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode) - ? (secretsModeRaw as SecretsMode) - : (() => { throw new Error("Invalid secretsMode parameter"); })()) - : "exclude"; +/** Parse export params from request body */ +function parseExportParamsFromBody(body: { + includeIds?: boolean; + includeTimestamps?: boolean; + includeRuntimeState?: boolean; + secretsMode?: SecretsMode; +}): ExportParams { + const includeIds = body.includeIds !== false; + const includeTimestamps = body.includeTimestamps !== false; + const includeRuntimeState = body.includeRuntimeState === true; + const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeIds, includeTimestamps, secretsMode, excludeKeys }; } -/** Get filter options from request query params */ -function getFilterOptions(c: Context): FilterOptions { - return { id: c.req.query("id"), name: c.req.query("name") }; +/** + * Verify password for export operation. + * All exports require password verification for security. + */ +async function verifyExportPassword( + c: Context, + password: string +): Promise<{ valid: true; userId: number } | { valid: false; error: string }> { + const sessionId = getCookie(c, COOKIE_NAME); + if (!sessionId) { + return { valid: false, error: "Not authenticated" }; + } + + const session = await authService.verifySession(sessionId); + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return { valid: false, error: "Session expired" }; + } + + const isValid = await authService.verifyPassword(session.user.id, password); + if (!isValid) { + return { valid: false, error: "Incorrect password" }; + } + + return { valid: true, userId: session.user.id }; } /** @@ -146,72 +179,6 @@ async function exportEntities>( return Promise.all(entities.map((e) => exportEntity(e as Record, params))); } -// ============================================================================ -// Data Fetchers with Filtering -// ============================================================================ - -async function fetchVolumes(filter: FilterOptions): Promise> { - if (filter.id) { - const id = Number.parseInt(filter.id, 10); - if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 }; - const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id)); - if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 }; - return { data: result }; - } - if (filter.name) { - const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name)); - if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 }; - return { data: result }; - } - return { data: await db.select().from(volumesTable) }; -} - -async function fetchRepositories(filter: FilterOptions): Promise> { - if (filter.id) { - // Validate UUID format - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(filter.id)) { - return { error: "Invalid repository ID format (expected UUID)", status: 400 }; - } - const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id)); - if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 }; - return { data: result }; - } - if (filter.name) { - const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name)); - if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 }; - return { data: result }; - } - return { data: await db.select().from(repositoriesTable) }; -} - -async function fetchNotifications(filter: FilterOptions): Promise> { - if (filter.id) { - const id = Number.parseInt(filter.id, 10); - if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 }; - const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id)); - if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 }; - return { data: result }; - } - if (filter.name) { - const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name)); - if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 }; - return { data: result }; - } - return { data: await db.select().from(notificationDestinationsTable) }; -} - -async function fetchBackupSchedules(filter: { id?: string }): Promise> { - if (filter.id) { - const id = Number.parseInt(filter.id, 10); - if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 }; - const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id)); - if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 }; - return { data: result }; - } - return { data: await db.select().from(backupSchedulesTable) }; -} - /** Transform backup schedules with resolved names and notifications */ function transformBackupSchedules( schedules: typeof backupSchedulesTable.$inferSelect[], @@ -241,34 +208,27 @@ function transformBackupSchedules( }); } -// ============================================================================ -// Controller -// ============================================================================ - -/** - * Config Export API - * - * Query parameters: - * - includeIds: "true" | "false" (default: "true") - Include database IDs - * - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt - * - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only) - * - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only) - * - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets - * - id: string (optional) - Filter by ID - * - name: string (optional) - Filter by name (not for backups) - */ export const configExportController = new Hono() - .get("/export", async (c) => { + .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true"; - const includePasswordHash = c.req.query("includePasswordHash") === "true"; + const body = c.req.valid("json") as FullExportBody; + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + const includeRecoveryKey = body.includeRecoveryKey === true; + const includePasswordHash = body.includePasswordHash === true; + + // Use services to fetch data const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ - db.select().from(volumesTable), - db.select().from(repositoriesTable), - db.select().from(backupSchedulesTable), - db.select().from(notificationDestinationsTable), + volumeService.listVolumes(), + repositoriesService.listRepositories(), + backupsService.listSchedules(), + notificationsService.listDestinations(), db.select().from(backupScheduleNotificationsTable), db.select().from(usersTable).limit(1), ]); @@ -281,9 +241,6 @@ export const configExportController = new Hono() backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); - // WARNING: As of now, volume exports may include sensitive data (e.g., SMB/NFS credentials) in cleartext. - // This is a known security limitation. Handle exported configuration files with care. - // Future PRs will implement encryption for these secrets. const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), @@ -318,60 +275,169 @@ export const configExportController = new Hono() return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); } }) - .get("/export/volumes", async (c) => { + .post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchVolumes(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR - return c.json({ volumes: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let volumes; + // Prefer name over id since volumeService.getVolume expects name (slug) + if (body.name) { + try { + const result = await volumeService.getVolume(body.name); + volumes = [result.volume]; + } catch { + return c.json({ error: `Volume '${body.name}' not found` }, 404); + } + } else if (body.id !== undefined) { + // If only ID provided, find volume by numeric ID from list + const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; + if (Number.isNaN(id)) { + return c.json({ error: "Invalid volume ID" }, 400); + } + const allVolumes = await volumeService.listVolumes(); + const volume = allVolumes.find((v) => v.id === id); + if (!volume) { + return c.json({ error: `Volume with ID '${body.id}' not found` }, 404); + } + volumes = [volume]; + } else { + volumes = await volumeService.listVolumes(); + } + + return c.json({ volumes: await exportEntities(volumes, params) }); } catch (err) { logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/repositories", async (c) => { + .post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchRepositories(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json({ repositories: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let repositories; + // Prefer name over id since repositoriesService.getRepository expects name (slug) + if (body.name) { + try { + const result = await repositoriesService.getRepository(body.name); + repositories = [result.repository]; + } catch { + return c.json({ error: `Repository '${body.name}' not found` }, 404); + } + } else if (body.id !== undefined) { + // If only ID provided, find repository by ID from list + // Repository IDs are strings (UUIDs), not numeric + const allRepositories = await repositoriesService.listRepositories(); + const repository = allRepositories.find((r) => r.id === String(body.id)); + if (!repository) { + return c.json({ error: `Repository with ID '${body.id}' not found` }, 404); + } + repositories = [repository]; + } else { + repositories = await repositoriesService.listRepositories(); + } + + return c.json({ repositories: await exportEntities(repositories, params) }); } catch (err) { logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/notification-destinations", async (c) => { + .post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => { try { - const params = parseExportParams(c); - const result = await fetchNotifications(getFilterOptions(c)); - if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json({ notificationDestinations: await exportEntities(result.data, params) }); + const body = c.req.valid("json") as EntityExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + + let notifications; + if (body.id !== undefined) { + const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; + if (Number.isNaN(id)) { + return c.json({ error: "Invalid notification destination ID" }, 400); + } + try { + const destination = await notificationsService.getDestination(id); + notifications = [destination]; + } catch { + return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404); + } + } else if (body.name) { + // notificationsService doesn't have getByName, so we list and filter + const allDestinations = await notificationsService.listDestinations(); + const destination = allDestinations.find((d) => d.name === body.name); + if (!destination) { + return c.json({ error: `Notification destination '${body.name}' not found` }, 404); + } + notifications = [destination]; + } else { + notifications = await notificationsService.listDestinations(); + } + + return c.json({ notificationDestinations: await exportEntities(notifications, params) }); } catch (err) { logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500); } }) - .get("/export/backup-schedules", async (c) => { + .post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => { try { - const params = parseExportParams(c); + const body = c.req.valid("json") as BackupScheduleExportBody; + + // Verify password - required for all exports + const verification = await verifyExportPassword(c, body.password); + if (!verification.valid) { + return c.json({ error: verification.error }, 401); + } + + const params = parseExportParamsFromBody(body); + // Get all related data for name resolution const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ - db.select().from(volumesTable), - db.select().from(repositoriesTable), - db.select().from(notificationDestinationsTable), + volumeService.listVolumes(), + repositoriesService.listRepositories(), + notificationsService.listDestinations(), db.select().from(backupScheduleNotificationsTable), ]); - const result = await fetchBackupSchedules({ id: c.req.query("id") }); - if ("error" in result) return c.json({ error: result.error }, result.status); + let schedules; + if (body.id !== undefined) { + try { + const schedule = await backupsService.getSchedule(body.id); + schedules = [schedule]; + } catch { + return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404); + } + } else { + schedules = await backupsService.listSchedules(); + } const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); const repoMap = new Map(repositories.map((r) => [r.id, r.name])); const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); const backupSchedules = transformBackupSchedules( - result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params + schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params ); return c.json({ backupSchedules }); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts new file mode 100644 index 00000000..f0e66859 --- /dev/null +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -0,0 +1,289 @@ +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); + +const baseExportBodySchema = type({ + /** Include database IDs in export (default: true) */ + "includeIds?": "boolean", + /** Include createdAt/updatedAt timestamps (default: true) */ + "includeTimestamps?": "boolean", + /** Include runtime state like status, health checks (default: false) */ + "includeRuntimeState?": "boolean", + /** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */ + "secretsMode?": secretsModeSchema, + /** Password required for authentication */ + password: "string", +}); + +export const fullExportBodySchema = baseExportBodySchema.and( + type({ + /** Include the recovery key (requires password) */ + "includeRecoveryKey?": "boolean", + /** Include the admin password hash */ + "includePasswordHash?": "boolean", + }) +); + +export const entityExportBodySchema = baseExportBodySchema.and( + type({ + /** Filter by ID */ + "id?": "string | number", + /** Filter by name */ + "name?": "string", + }) +); + +export const backupScheduleExportBodySchema = baseExportBodySchema.and( + type({ + /** Filter by ID */ + "id?": "number", + }) +); + +export type FullExportBody = typeof fullExportBodySchema.infer; +export type EntityExportBody = typeof entityExportBodySchema.infer; +export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer; +export type SecretsMode = typeof secretsModeSchema.infer; + +const exportResponseSchema = type({ + "version?": "number", + "exportedAt?": "string", + "volumes?": "unknown[]", + "repositories?": "unknown[]", + "backupSchedules?": "unknown[]", + "notificationDestinations?": "unknown[]", + "admin?": type({ + username: "string", + "passwordHash?": "string", + "recoveryKey?": "string", + }).or("null"), +}); + +const volumesExportResponseSchema = type({ + volumes: "unknown[]", +}); + +const repositoriesExportResponseSchema = type({ + repositories: "unknown[]", +}); + +const notificationsExportResponseSchema = type({ + notificationDestinations: "unknown[]", +}); + +const backupSchedulesExportResponseSchema = type({ + backupSchedules: "unknown[]", +}); + +const errorResponseSchema = type({ + error: "string", +}); + +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), + }, + }, + }, + 401: { + description: "Password required for sensitive export options", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); + +export const volumesExportDto = describeRoute({ + description: "Export volumes configuration", + operationId: "exportVolumes", + tags: ["Config Export"], + responses: { + 200: { + description: "Volumes configuration export", + content: { + "application/json": { + schema: resolver(volumesExportResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 404: { + description: "Volume not found", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); + +export const repositoriesExportDto = describeRoute({ + description: "Export repositories configuration", + operationId: "exportRepositories", + tags: ["Config Export"], + responses: { + 200: { + description: "Repositories configuration export", + content: { + "application/json": { + schema: resolver(repositoriesExportResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 401: { + description: "Password required for sensitive export options", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 404: { + description: "Repository not found", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); + +export const notificationsExportDto = describeRoute({ + description: "Export notification destinations configuration", + operationId: "exportNotificationDestinations", + tags: ["Config Export"], + responses: { + 200: { + description: "Notification destinations configuration export", + content: { + "application/json": { + schema: resolver(notificationsExportResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 401: { + description: "Password required for sensitive export options", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 404: { + description: "Notification destination not found", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); + +export const backupSchedulesExportDto = describeRoute({ + description: "Export backup schedules configuration", + operationId: "exportBackupSchedules", + tags: ["Config Export"], + responses: { + 200: { + description: "Backup schedules configuration export", + content: { + "application/json": { + schema: resolver(backupSchedulesExportResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 404: { + description: "Backup schedule not found", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + 500: { + description: "Export failed", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, + }, +}); From a7e36e98d7e4fe79809a6f2b1c636377f33236aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Wed, 3 Dec 2025 08:33:50 +0100 Subject: [PATCH 32/49] Add 401 error response for password requirement in export endpoints --- app/client/api-client/types.gen.ts | 12 ++++++++++++ .../modules/lifecycle/config-export.dto.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index d025a2fe..33078a5d 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2691,6 +2691,12 @@ export type ExportVolumesErrors = { 400: { error: string; }; + /** + * Password required for export + */ + 401: { + error: string; + }; /** * Volume not found */ @@ -2849,6 +2855,12 @@ export type ExportBackupSchedulesErrors = { 400: { error: string; }; + /** + * Password required for export + */ + 401: { + error: string; + }; /** * Backup schedule not found */ diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index f0e66859..ce5d53ab 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -133,6 +133,14 @@ export const volumesExportDto = describeRoute({ }, }, }, + 401: { + description: "Password required for export", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, 404: { description: "Volume not found", content: { @@ -269,6 +277,14 @@ export const backupSchedulesExportDto = describeRoute({ }, }, }, + 401: { + description: "Password required for export", + content: { + "application/json": { + schema: resolver(errorResponseSchema), + }, + }, + }, 404: { description: "Backup schedule not found", content: { From 692dd5aa2172a70c50c94f37c864ef399999e944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Wed, 3 Dec 2025 23:50:51 +0100 Subject: [PATCH 33/49] add isEncrypted check to decrypt as a safeguard --- app/server/utils/crypto.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index 1ed05d2a..5394cefd 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -17,7 +17,7 @@ const encrypt = async (data: string) => { return data; } - if (data.startsWith(encryptionPrefix)) { + if (isEncrypted(data)) { return data; } @@ -38,6 +38,10 @@ const encrypt = async (data: string) => { * Given an encrypted string, decrypts it using the salt stored in the string */ const decrypt = async (encryptedData: string) => { + if (!isEncrypted(encryptedData)) { + return encryptedData; + } + const secret = await Bun.file(RESTIC_PASS_FILE).text(); const parts = encryptedData.split(":").slice(1); // Remove prefix From bf36f9aa56c1ab3fda51bd405c858b2620e06fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Tue, 16 Dec 2025 11:39:35 +0100 Subject: [PATCH 34/49] reduced the scope of PR to MVP - exportAll only --- README.md | 19 +- .../api-client/@tanstack/react-query.gen.ts | 72 +--- app/client/api-client/sdk.gen.ts | 58 +-- app/client/api-client/types.gen.ts | 223 +----------- app/client/components/export-dialog.tsx | 336 ++++-------------- .../backups/components/schedule-summary.tsx | 2 - app/client/modules/backups/routes/backups.tsx | 6 - .../routes/notification-details.tsx | 4 +- .../notifications/routes/notifications.tsx | 12 +- .../repositories/routes/repositories.tsx | 12 +- .../routes/repository-details.tsx | 4 +- .../modules/settings/routes/settings.tsx | 18 +- .../modules/volumes/routes/volume-details.tsx | 3 - app/client/modules/volumes/routes/volumes.tsx | 12 +- .../lifecycle/config-export.controller.ts | 307 +++------------- .../modules/lifecycle/config-export.dto.ts | 249 +------------ 16 files changed, 182 insertions(+), 1155 deletions(-) diff --git a/README.md b/README.md index b9732a9c..f9838551 100644 --- a/README.md +++ b/README.md @@ -198,26 +198,21 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n ## Exporting configuration -Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export: - -- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations -- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules +Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. To export, click the "Export" button on any list page or detail page. A dialog will appear with options to: -- **Include database IDs** - Useful for debugging or when you need to reference internal identifiers -- **Include timestamps** - Include createdAt/updatedAt fields in the export -- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration) -- **Secrets handling** (for repositories and notifications): +- **Include metadata** - Include IDs, timestamps, and runtime state of entities +- **Secrets handling**: - **Exclude** - Remove sensitive fields like passwords and API keys - **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import) - **Decrypt** - Export secrets as plaintext (use with caution) -- **Include recovery key** (full export only) - Include the master encryption key for all repositories -- **Include password hash** (full export only) - Include the hashed admin password for seamless migration +- **Include recovery key** - Include the master encryption key for all repositories +- **Include password hash** - Include the hashed user passwords for seamless migration -All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported. +Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported. -Exports are downloaded as JSON files that can be used for reference or future import functionality. +Export is downloaded as JSON file that can be used for reference or future import functionality. ## Propagating mounts to host diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 4114ad91..fab5ce72 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, deleteVolume, doctorRepository, downloadResticPassword, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, 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, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, 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, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, 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, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, 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, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -910,71 +910,3 @@ export const exportFullConfigMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportVolumes({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export repositories configuration - */ -export const exportRepositoriesMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportRepositories({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export notification destinations configuration - */ -export const exportNotificationDestinationsMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportNotificationDestinations({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Export backup schedules configuration - */ -export const exportBackupSchedulesMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await exportBackupSchedules({ - ...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 ea0d832f..7778eb87 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, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportBackupSchedulesData, ExportBackupSchedulesErrors, ExportBackupSchedulesResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, ExportNotificationDestinationsData, ExportNotificationDestinationsErrors, ExportNotificationDestinationsResponses, ExportRepositoriesData, ExportRepositoriesErrors, ExportRepositoriesResponses, ExportVolumesData, ExportVolumesErrors, ExportVolumesResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, 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, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, 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, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, 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, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -581,59 +581,3 @@ export const exportFullConfig = (options?: } }); }; - -/** - * Export volumes configuration - */ -export const exportVolumes = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/volumes', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export repositories configuration - */ -export const exportRepositories = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/repositories', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export notification destinations configuration - */ -export const exportNotificationDestinations = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/notification-destinations', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - -/** - * Export backup schedules configuration - */ -export const exportBackupSchedules = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/config/export/backup-schedules', - ...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 33078a5d..efb9bb73 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2619,11 +2619,9 @@ export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[key export type ExportFullConfigData = { body?: { password: string; - includeIds?: boolean; + includeMetadata?: boolean; includePasswordHash?: boolean; includeRecoveryKey?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; }; path?: never; @@ -2668,222 +2666,3 @@ export type ExportFullConfigResponses = { }; export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses]; - -export type ExportVolumesData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/volumes'; -}; - -export type ExportVolumesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for export - */ - 401: { - error: string; - }; - /** - * Volume not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportVolumesError = ExportVolumesErrors[keyof ExportVolumesErrors]; - -export type ExportVolumesResponses = { - /** - * Volumes configuration export - */ - 200: { - volumes: Array; - }; -}; - -export type ExportVolumesResponse = ExportVolumesResponses[keyof ExportVolumesResponses]; - -export type ExportRepositoriesData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/repositories'; -}; - -export type ExportRepositoriesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for sensitive export options - */ - 401: { - error: string; - }; - /** - * Repository not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportRepositoriesError = ExportRepositoriesErrors[keyof ExportRepositoriesErrors]; - -export type ExportRepositoriesResponses = { - /** - * Repositories configuration export - */ - 200: { - repositories: Array; - }; -}; - -export type ExportRepositoriesResponse = ExportRepositoriesResponses[keyof ExportRepositoriesResponses]; - -export type ExportNotificationDestinationsData = { - body?: { - password: string; - id?: number | string; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - name?: string; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/notification-destinations'; -}; - -export type ExportNotificationDestinationsErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for sensitive export options - */ - 401: { - error: string; - }; - /** - * Notification destination not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportNotificationDestinationsError = ExportNotificationDestinationsErrors[keyof ExportNotificationDestinationsErrors]; - -export type ExportNotificationDestinationsResponses = { - /** - * Notification destinations configuration export - */ - 200: { - notificationDestinations: Array; - }; -}; - -export type ExportNotificationDestinationsResponse = ExportNotificationDestinationsResponses[keyof ExportNotificationDestinationsResponses]; - -export type ExportBackupSchedulesData = { - body?: { - password: string; - id?: number; - includeIds?: boolean; - includeRuntimeState?: boolean; - includeTimestamps?: boolean; - secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; - }; - path?: never; - query?: never; - url: '/api/v1/config/export/backup-schedules'; -}; - -export type ExportBackupSchedulesErrors = { - /** - * Invalid request - */ - 400: { - error: string; - }; - /** - * Password required for export - */ - 401: { - error: string; - }; - /** - * Backup schedule not found - */ - 404: { - error: string; - }; - /** - * Export failed - */ - 500: { - error: string; - }; -}; - -export type ExportBackupSchedulesError = ExportBackupSchedulesErrors[keyof ExportBackupSchedulesErrors]; - -export type ExportBackupSchedulesResponses = { - /** - * Backup schedules configuration export - */ - 200: { - backupSchedules: Array; - }; -}; - -export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses]; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 3d3a208d..36c5cdd8 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -1,14 +1,8 @@ import { useMutation } from "@tanstack/react-query"; import { Download } from "lucide-react"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { toast } from "sonner"; -import { - exportBackupSchedulesMutation, - exportFullConfigMutation, - exportNotificationDestinationsMutation, - exportRepositoriesMutation, - exportVolumesMutation, -} 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 { Checkbox } from "~/client/components/ui/checkbox"; import { @@ -30,7 +24,9 @@ import { SelectValue, } from "~/client/components/ui/select"; -export type SecretsMode = "exclude" | "encrypted" | "cleartext"; +type SecretsMode = "exclude" | "encrypted" | "cleartext"; + +const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config"; function downloadAsJson(data: unknown, filename: string): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); @@ -44,60 +40,14 @@ function downloadAsJson(data: unknown, filename: string): void { URL.revokeObjectURL(url); } -export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full"; - -type ExportConfig = { - label: string; - labelPlural: string; - getFilename: (id?: string | number, name?: string) => string; -}; - -const exportConfigs: Record = { - volumes: { - label: "Volume", - labelPlural: "Volumes", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `volume-${identifier}-config` : "volumes-config"; - }, - }, - repositories: { - label: "Repository", - labelPlural: "Repositories", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `repository-${identifier}-config` : "repositories-config"; - }, - }, - "notification-destinations": { - label: "Notification Destination", - labelPlural: "Notification Destinations", - getFilename: (id, name) => { - const identifier = id ?? name; - return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config"; - }, - }, - "backup-schedules": { - label: "Backup Schedule", - labelPlural: "Backup Schedules", - getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"), - }, - full: { - label: "Full Config", - labelPlural: "Full Config", - getFilename: () => "zerobyte-full-config", - }, -}; - type BaseExportDialogProps = { - entityType: ExportEntityType; - name?: string; - id?: string | number; + /** 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: React.ReactNode; + trigger: ReactNode; variant?: never; size?: never; triggerLabel?: never; @@ -115,9 +65,7 @@ type ExportDialogWithDefaultTrigger = BaseExportDialogProps & { type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger; export function ExportDialog({ - entityType, - name, - id, + filename = DEFAULT_EXPORT_FILENAME, trigger, variant = "outline", size = "default", @@ -125,85 +73,35 @@ export function ExportDialog({ showIcon = true, }: ExportDialogProps) { const [open, setOpen] = useState(false); - const [includeIds, setIncludeIds] = useState(true); - const [includeTimestamps, setIncludeTimestamps] = useState(true); - const [includeRuntimeState, setIncludeRuntimeState] = useState(false); + const [includeMetadata, setIncludeMetadata] = useState(false); const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false); const [secretsMode, setSecretsMode] = useState("exclude"); const [password, setPassword] = useState(""); - const config = exportConfigs[entityType]; - const isSingleItem = !!(name || id); - const isFullExport = entityType === "full"; - // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR - const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes"; - const entityLabel = isSingleItem ? config.label : config.labelPlural; - const filename = config.getFilename(id, name); - const handleExportSuccess = (data: unknown) => { downloadAsJson(data, filename); - toast.success(`${entityLabel} exported successfully`); + 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"; + const message = + error && typeof error === "object" && "error" in error + ? (error as { error: string }).error + : "Unknown error"; toast.error("Export failed", { description: message, }); }; - const fullExport = useMutation({ + const exportMutation = useMutation({ ...exportFullConfigMutation(), onSuccess: handleExportSuccess, onError: handleExportError, }); - const volumesExport = useMutation({ - ...exportVolumesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const repositoriesExport = useMutation({ - ...exportRepositoriesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const notificationsExport = useMutation({ - ...exportNotificationDestinationsMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const backupSchedulesExport = useMutation({ - ...exportBackupSchedulesMutation(), - onSuccess: handleExportSuccess, - onError: handleExportError, - }); - - const getMutation = () => { - switch (entityType) { - case "full": - return fullExport; - case "volumes": - return volumesExport; - case "repositories": - return repositoriesExport; - case "notification-destinations": - return notificationsExport; - case "backup-schedules": - return backupSchedulesExport; - } - }; - - const exportMutation = getMutation(); - const handleExport = (e: React.FormEvent) => { e.preventDefault(); if (!password) { @@ -211,60 +109,15 @@ export function ExportDialog({ return; } - const baseBody = { - password, - includeIds, - includeTimestamps, - includeRuntimeState, - secretsMode: hasSecrets ? secretsMode : undefined, - }; - - switch (entityType) { - case "full": - fullExport.mutate({ - body: { - ...baseBody, - includeRecoveryKey, - includePasswordHash, - }, - }); - break; - case "volumes": - volumesExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "repositories": - repositoriesExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "notification-destinations": - notificationsExport.mutate({ - body: { - ...baseBody, - id: id as number | string | undefined, - name, - }, - }); - break; - case "backup-schedules": - backupSchedulesExport.mutate({ - body: { - ...baseBody, - id: typeof id === "number" ? id : undefined, - }, - }); - break; - } + exportMutation.mutate({ + body: { + password, + includeMetadata, + secretsMode, + includeRecoveryKey, + includePasswordHash, + }, + }); }; const handleDialogChange = (isOpen: boolean) => { @@ -276,19 +129,19 @@ export function ExportDialog({ const defaultTrigger = variant === "card" ? ( - ) : ( ); @@ -298,118 +151,83 @@ export function ExportDialog({
- Export {entityLabel} + Export Full Configuration - {isFullExport - ? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications." - : isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + Export the complete Zerobyte configuration.
setIncludeIds(checked === true)} + id="includeMetadata" + checked={includeMetadata} + onCheckedChange={(checked) => setIncludeMetadata(checked === true)} /> -

- Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference. + Include database IDs, timestamps, and runtime state (status, health checks, last backup info).

-
+
+ + +
+

+ {secretsMode === "exclude" && + "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} + {secretsMode === "encrypted" && + "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} + {secretsMode === "cleartext" && ( + + ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! + + )} +

+ +
setIncludeTimestamps(checked === true)} + id="includeRecoveryKey" + checked={includeRecoveryKey} + onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)} /> -

- Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed. + ⚠️ Security sensitive: The recovery + key is the master encryption key for all repositories. Keep this export secure and never + share it.

setIncludeRuntimeState(checked === true)} + id="includePasswordHash" + checked={includePasswordHash} + onCheckedChange={(checked) => setIncludePasswordHash(checked === true)} /> -

- Include current status, health checks, and last backup information. Usually not needed for migration. + Include the hashed admin password for seamless migration. The password is already securely + hashed (argon2).

- {hasSecrets && ( - <> -
- - -
-

- {secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."} - {secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."} - {secretsMode === "cleartext" && ( - - ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! - - )} -

- - )} - - {isFullExport && ( - <> -
- setIncludeRecoveryKey(checked === true)} - /> - -
-

- ⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it. -

- -
- setIncludePasswordHash(checked === true)} - /> - -
-

- Include the hashed admin password for seamless migration. The password is already securely hashed (argon2). -

- - )} -
{ Edit schedule -
); diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index c3598cf6..082f475d 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -24,8 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen"; import type { Route } from "./+types/notification-details"; import { cn } from "~/client/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; -import { Bell, TestTube2, Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; +import { Bell, TestTube2 } from "lucide-react"; import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; @@ -143,7 +142,6 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP Test - )} -
- - -
+
diff --git a/app/client/modules/repositories/routes/repositories.tsx b/app/client/modules/repositories/routes/repositories.tsx index 40fc4d93..4e65bacd 100644 --- a/app/client/modules/repositories/routes/repositories.tsx +++ b/app/client/modules/repositories/routes/repositories.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { Database, Plus, RotateCcw } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { listRepositories } from "~/client/api-client/sdk.gen"; @@ -120,13 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) { )} -
- - -
+
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index b2ccc155..394b7d7b 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -25,8 +25,7 @@ import { cn } from "~/client/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; -import { Loader2, Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; +import { Loader2 } from "lucide-react"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -158,7 +157,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro "Run Doctor" )} - diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 511793d9..cd107e24 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -144,9 +144,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) { Your account details -
- -
@@ -266,6 +263,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/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index d4cf8fba..3ee690a3 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -25,8 +25,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/ import { useSystemInfo } from "~/client/hooks/use-system-info"; import { getVolume } from "~/client/api-client"; import type { VolumeStatus } from "~/client/lib/types"; -import { Trash2 } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { deleteVolumeMutation, getVolumeOptions, @@ -162,7 +160,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) { > Unmount - diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 14da0b13..fc8bbda4 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { HardDrive, Plus, RotateCcw } from "lucide-react"; -import { ExportDialog } from "~/client/components/export-dialog"; import { useState } from "react"; import { useNavigate } from "react-router"; import { EmptyState } from "~/client/components/empty-state"; @@ -130,13 +129,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { )} -
- - -
+
diff --git a/app/server/modules/lifecycle/config-export.controller.ts b/app/server/modules/lifecycle/config-export.controller.ts index 774b9206..cb2547c6 100644 --- a/app/server/modules/lifecycle/config-export.controller.ts +++ b/app/server/modules/lifecycle/config-export.controller.ts @@ -1,13 +1,8 @@ import { validator } from "hono-openapi"; - import { Hono } from "hono"; import type { Context } from "hono"; import { deleteCookie, getCookie } from "hono/cookie"; -import { - backupSchedulesTable, - backupScheduleNotificationsTable, - usersTable, -} from "../../db/schema"; +import { backupSchedulesTable, backupScheduleNotificationsTable, usersTable } from "../../db/schema"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { RESTIC_PASS_FILE } from "../../core/constants"; @@ -19,17 +14,9 @@ import { notificationsService } from "../notifications/notifications.service"; import { backupsService } from "../backups/backups.service"; import { fullExportBodySchema, - entityExportBodySchema, - backupScheduleExportBodySchema, fullExportDto, - volumesExportDto, - repositoriesExportDto, - notificationsExportDto, - backupSchedulesExportDto, type SecretsMode, type FullExportBody, - type EntityExportBody, - type BackupScheduleExportBody, } from "./config-export.dto"; const COOKIE_NAME = "session_id"; @@ -41,56 +28,39 @@ const COOKIE_OPTIONS = { }; type ExportParams = { - includeIds: boolean; - includeTimestamps: boolean; + includeMetadata: boolean; secretsMode: SecretsMode; - excludeKeys: string[]; }; -function omitKeys>(obj: T, keys: string[]): Partial { +// Keys to exclude when metadata is not included +const METADATA_KEYS = { + ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"], + timestamps: ["createdAt", "updatedAt", "lastBackupAt", "nextBackupAt", "lastHealthCheck", "lastChecked"], + runtimeState: ["status", "lastError", "lastBackupStatus", "lastBackupError", "hasDownloadedResticPassword"], +}; + +const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState]; + +/** Filter out metadata keys from an object when includeMetadata is false */ +function filterMetadataOut>(obj: T, includeMetadata: boolean): Partial { + if (includeMetadata) { + return obj; + } const result = { ...obj }; - for (const key of keys) { + for (const key of ALL_METADATA_KEYS) { delete result[key as keyof T]; } return result; } -function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, includeRuntimeState: boolean): string[] { - const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"]; - const timestampKeys = ["createdAt", "updatedAt"]; - // Runtime state fields (status, health checks, last backup info, etc.) - const runtimeStateKeys = [ - // Volume state - "status", "lastError", "lastHealthCheck", - // Repository state - "lastChecked", - // Backup schedule state - "lastBackupAt", "lastBackupStatus", "lastBackupError", "nextBackupAt", - ]; - // Redundant fields that are already present inside the config object - // (e.g., type is duplicated as config.backend or config.type) - const redundantKeys = ["type"]; - return [ - ...redundantKeys, - ...(includeRuntimeState ? [] : runtimeStateKeys), - ...(includeIds ? [] : idKeys), - ...(includeTimestamps ? [] : timestampKeys), - ]; -} - /** Parse export params from request body */ function parseExportParamsFromBody(body: { - includeIds?: boolean; - includeTimestamps?: boolean; - includeRuntimeState?: boolean; + includeMetadata?: boolean; secretsMode?: SecretsMode; }): ExportParams { - const includeIds = body.includeIds !== false; - const includeTimestamps = body.includeTimestamps !== false; - const includeRuntimeState = body.includeRuntimeState === true; + const includeMetadata = body.includeMetadata === true; const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; - const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); - return { includeIds, includeTimestamps, secretsMode, excludeKeys }; + return { includeMetadata, secretsMode }; } /** @@ -167,7 +137,7 @@ async function exportEntity( entity: Record, params: ExportParams ): Promise> { - const cleaned = omitKeys(entity, params.excludeKeys); + const cleaned = filterMetadataOut(entity, params.includeMetadata); return processSecrets(cleaned, params.secretsMode); } @@ -181,8 +151,8 @@ async function exportEntities>( /** Transform backup schedules with resolved names and notifications */ function transformBackupSchedules( - schedules: typeof backupSchedulesTable.$inferSelect[], - scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[], + schedules: (typeof backupSchedulesTable.$inferSelect)[], + scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[], volumeMap: Map, repoMap: Map, notificationMap: Map, @@ -192,15 +162,12 @@ function transformBackupSchedules( const assignments = scheduleNotifications .filter((sn) => sn.scheduleId === schedule.id) .map((sn) => ({ - ...(params.includeIds ? { destinationId: sn.destinationId } : {}), + ...filterMetadataOut(sn as unknown as Record, params.includeMetadata), name: notificationMap.get(sn.destinationId) ?? null, - notifyOnStart: sn.notifyOnStart, - notifyOnSuccess: sn.notifyOnSuccess, - notifyOnFailure: sn.notifyOnFailure, })); return { - ...omitKeys(schedule as Record, params.excludeKeys), + ...filterMetadataOut(schedule as Record, params.includeMetadata), volume: volumeMap.get(schedule.volumeId) ?? null, repository: repoMap.get(schedule.repositoryId) ?? null, notifications: assignments, @@ -208,8 +175,11 @@ function transformBackupSchedules( }); } -export const configExportController = new Hono() - .post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { +export const configExportController = new Hono().post( + "/export", + fullExportDto, + validator("json", fullExportBodySchema), + async (c) => { try { const body = c.req.valid("json") as FullExportBody; @@ -224,21 +194,27 @@ export const configExportController = new Hono() const includePasswordHash = body.includePasswordHash === true; // Use services to fetch data - const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ - volumeService.listVolumes(), - repositoriesService.listRepositories(), - backupsService.listSchedules(), - notificationsService.listDestinations(), - db.select().from(backupScheduleNotificationsTable), - db.select().from(usersTable).limit(1), - ]); + const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, users] = + await Promise.all([ + volumeService.listVolumes(), + repositoriesService.listRepositories(), + backupsService.listSchedules(), + notificationsService.listDestinations(), + db.select().from(backupScheduleNotificationsTable), + db.select().from(usersTable), + ]); const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); const repoMap = new Map(repositories.map((r) => [r.id, r.name])); const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); const backupSchedules = transformBackupSchedules( - backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params + backupSchedulesRaw, + scheduleNotifications, + volumeMap, + repoMap, + notificationMap, + params ); const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ @@ -257,194 +233,27 @@ export const configExportController = new Hono() } } + // Users need special handling for passwordHash (controlled by separate flag) + const exportUsers = (await exportEntities(users, params)).map((user) => { + if (!includePasswordHash) { + delete user.passwordHash; + } + return user; + }); + return c.json({ version: 1, - exportedAt: new Date().toISOString(), + ...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}), + ...(recoveryKey ? { recoveryKey } : {}), volumes: exportVolumes, repositories: exportRepositories, backupSchedules, notificationDestinations: exportNotifications, - admin: admin ? { - username: admin.username, - ...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}), - ...(recoveryKey ? { recoveryKey } : {}), - } : null, + users: exportUsers, }); } catch (err) { logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`); return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500); } - }) - .post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let volumes; - // Prefer name over id since volumeService.getVolume expects name (slug) - if (body.name) { - try { - const result = await volumeService.getVolume(body.name); - volumes = [result.volume]; - } catch { - return c.json({ error: `Volume '${body.name}' not found` }, 404); - } - } else if (body.id !== undefined) { - // If only ID provided, find volume by numeric ID from list - const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; - if (Number.isNaN(id)) { - return c.json({ error: "Invalid volume ID" }, 400); - } - const allVolumes = await volumeService.listVolumes(); - const volume = allVolumes.find((v) => v.id === id); - if (!volume) { - return c.json({ error: `Volume with ID '${body.id}' not found` }, 404); - } - volumes = [volume]; - } else { - volumes = await volumeService.listVolumes(); - } - - return c.json({ volumes: await exportEntities(volumes, params) }); - } catch (err) { - logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let repositories; - // Prefer name over id since repositoriesService.getRepository expects name (slug) - if (body.name) { - try { - const result = await repositoriesService.getRepository(body.name); - repositories = [result.repository]; - } catch { - return c.json({ error: `Repository '${body.name}' not found` }, 404); - } - } else if (body.id !== undefined) { - // If only ID provided, find repository by ID from list - // Repository IDs are strings (UUIDs), not numeric - const allRepositories = await repositoriesService.listRepositories(); - const repository = allRepositories.find((r) => r.id === String(body.id)); - if (!repository) { - return c.json({ error: `Repository with ID '${body.id}' not found` }, 404); - } - repositories = [repository]; - } else { - repositories = await repositoriesService.listRepositories(); - } - - return c.json({ repositories: await exportEntities(repositories, params) }); - } catch (err) { - logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as EntityExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - let notifications; - if (body.id !== undefined) { - const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id; - if (Number.isNaN(id)) { - return c.json({ error: "Invalid notification destination ID" }, 400); - } - try { - const destination = await notificationsService.getDestination(id); - notifications = [destination]; - } catch { - return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404); - } - } else if (body.name) { - // notificationsService doesn't have getByName, so we list and filter - const allDestinations = await notificationsService.listDestinations(); - const destination = allDestinations.find((d) => d.name === body.name); - if (!destination) { - return c.json({ error: `Notification destination '${body.name}' not found` }, 404); - } - notifications = [destination]; - } else { - notifications = await notificationsService.listDestinations(); - } - - return c.json({ notificationDestinations: await exportEntities(notifications, params) }); - } catch (err) { - logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }) - .post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => { - try { - const body = c.req.valid("json") as BackupScheduleExportBody; - - // Verify password - required for all exports - const verification = await verifyExportPassword(c, body.password); - if (!verification.valid) { - return c.json({ error: verification.error }, 401); - } - - const params = parseExportParamsFromBody(body); - - // Get all related data for name resolution - const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([ - volumeService.listVolumes(), - repositoriesService.listRepositories(), - notificationsService.listDestinations(), - db.select().from(backupScheduleNotificationsTable), - ]); - - let schedules; - if (body.id !== undefined) { - try { - const schedule = await backupsService.getSchedule(body.id); - schedules = [schedule]; - } catch { - return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404); - } - } else { - schedules = await backupsService.listSchedules(); - } - - const volumeMap = new Map(volumes.map((v) => [v.id, v.name])); - const repoMap = new Map(repositories.map((r) => [r.id, r.name])); - const notificationMap = new Map(notifications.map((n) => [n.id, n.name])); - - const backupSchedules = transformBackupSchedules( - schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params - ); - - return c.json({ backupSchedules }); - } catch (err) { - logger.error(`Backup schedules export failed: ${err instanceof Error ? err.message : String(err)}`); - return c.json({ error: `Failed to export backup schedules: ${err instanceof Error ? err.message : String(err)}` }, 500); - } - }); - - + } +); diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index ce5d53ab..e4da4b48 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -3,47 +3,20 @@ import { describeRoute, resolver } from "hono-openapi"; const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); -const baseExportBodySchema = type({ - /** Include database IDs in export (default: true) */ - "includeIds?": "boolean", - /** Include createdAt/updatedAt timestamps (default: true) */ - "includeTimestamps?": "boolean", - /** Include runtime state like status, health checks (default: false) */ - "includeRuntimeState?": "boolean", +export const fullExportBodySchema = type({ + /** Include metadata (IDs, timestamps, runtime state) in export (default: false) */ + "includeMetadata?": "boolean", /** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */ "secretsMode?": secretsModeSchema, /** Password required for authentication */ password: "string", + /** Include the recovery key (requires password) */ + "includeRecoveryKey?": "boolean", + /** Include the admin password hash */ + "includePasswordHash?": "boolean", }); -export const fullExportBodySchema = baseExportBodySchema.and( - type({ - /** Include the recovery key (requires password) */ - "includeRecoveryKey?": "boolean", - /** Include the admin password hash */ - "includePasswordHash?": "boolean", - }) -); - -export const entityExportBodySchema = baseExportBodySchema.and( - type({ - /** Filter by ID */ - "id?": "string | number", - /** Filter by name */ - "name?": "string", - }) -); - -export const backupScheduleExportBodySchema = baseExportBodySchema.and( - type({ - /** Filter by ID */ - "id?": "number", - }) -); - export type FullExportBody = typeof fullExportBodySchema.infer; -export type EntityExportBody = typeof entityExportBodySchema.infer; -export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer; export type SecretsMode = typeof secretsModeSchema.infer; const exportResponseSchema = type({ @@ -60,22 +33,6 @@ const exportResponseSchema = type({ }).or("null"), }); -const volumesExportResponseSchema = type({ - volumes: "unknown[]", -}); - -const repositoriesExportResponseSchema = type({ - repositories: "unknown[]", -}); - -const notificationsExportResponseSchema = type({ - notificationDestinations: "unknown[]", -}); - -const backupSchedulesExportResponseSchema = type({ - backupSchedules: "unknown[]", -}); - const errorResponseSchema = type({ error: "string", }); @@ -111,195 +68,3 @@ export const fullExportDto = describeRoute({ }, }, }); - -export const volumesExportDto = describeRoute({ - description: "Export volumes configuration", - operationId: "exportVolumes", - tags: ["Config Export"], - responses: { - 200: { - description: "Volumes configuration export", - content: { - "application/json": { - schema: resolver(volumesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for export", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Volume not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const repositoriesExportDto = describeRoute({ - description: "Export repositories configuration", - operationId: "exportRepositories", - tags: ["Config Export"], - responses: { - 200: { - description: "Repositories configuration export", - content: { - "application/json": { - schema: resolver(repositoriesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for sensitive export options", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Repository not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const notificationsExportDto = describeRoute({ - description: "Export notification destinations configuration", - operationId: "exportNotificationDestinations", - tags: ["Config Export"], - responses: { - 200: { - description: "Notification destinations configuration export", - content: { - "application/json": { - schema: resolver(notificationsExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for sensitive export options", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Notification destination not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); - -export const backupSchedulesExportDto = describeRoute({ - description: "Export backup schedules configuration", - operationId: "exportBackupSchedules", - tags: ["Config Export"], - responses: { - 200: { - description: "Backup schedules configuration export", - content: { - "application/json": { - schema: resolver(backupSchedulesExportResponseSchema), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 401: { - description: "Password required for export", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 404: { - description: "Backup schedule not found", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - 500: { - description: "Export failed", - content: { - "application/json": { - schema: resolver(errorResponseSchema), - }, - }, - }, - }, -}); From 63f67b3b302a67c0c938b9fa67a5d12115088a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Tue, 16 Dec 2025 11:48:55 +0100 Subject: [PATCH 35/49] corrected schema for export after changes --- app/server/modules/lifecycle/config-export.dto.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index e4da4b48..0d4399d1 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -20,17 +20,21 @@ export type FullExportBody = typeof fullExportBodySchema.infer; export type SecretsMode = typeof secretsModeSchema.infer; const exportResponseSchema = type({ - "version?": "number", + version: "number", "exportedAt?": "string", + "recoveryKey?": "string", "volumes?": "unknown[]", "repositories?": "unknown[]", "backupSchedules?": "unknown[]", "notificationDestinations?": "unknown[]", - "admin?": type({ + "users?": type({ + "id?": "number", username: "string", "passwordHash?": "string", - "recoveryKey?": "string", - }).or("null"), + "createdAt?": "number", + "updatedAt?": "number", + "hasDownloadedResticPassword?": "boolean", + }).array(), }); const errorResponseSchema = type({ From 66f8ff0e509245c2c92781de5a20f2a54aec494b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Tue, 16 Dec 2025 12:47:20 +0100 Subject: [PATCH 36/49] few missing corrections related to all users export instead of just admin --- app/client/api-client/types.gen.ts | 18 +++++++++++------- app/client/components/export-dialog.tsx | 2 +- .../modules/lifecycle/config-export.dto.ts | 6 +++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index efb9bb73..d196feef 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2631,7 +2631,7 @@ export type ExportFullConfigData = { export type ExportFullConfigErrors = { /** - * Password required for sensitive export options + * Password required for export or authentication failed */ 401: { error: string; @@ -2651,16 +2651,20 @@ export type ExportFullConfigResponses = { * Full configuration export */ 200: { - admin?: { - username: string; - passwordHash?: string; - recoveryKey?: string; - } | null; + version: number; backupSchedules?: Array; exportedAt?: string; notificationDestinations?: Array; + recoveryKey?: string; repositories?: Array; - version?: number; + users?: Array<{ + username: string; + createdAt?: number; + hasDownloadedResticPassword?: boolean; + id?: number; + passwordHash?: string; + updatedAt?: number; + }>; volumes?: Array; }; }; diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index 36c5cdd8..00db200b 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -224,7 +224,7 @@ export function ExportDialog({

- Include the hashed admin password for seamless migration. The password is already securely + Include the hashed user passwords for seamless migration. The passwords are already securely hashed (argon2).

diff --git a/app/server/modules/lifecycle/config-export.dto.ts b/app/server/modules/lifecycle/config-export.dto.ts index 0d4399d1..f7a57561 100644 --- a/app/server/modules/lifecycle/config-export.dto.ts +++ b/app/server/modules/lifecycle/config-export.dto.ts @@ -10,9 +10,9 @@ export const fullExportBodySchema = type({ "secretsMode?": secretsModeSchema, /** Password required for authentication */ password: "string", - /** Include the recovery key (requires password) */ + /** Include the recovery key */ "includeRecoveryKey?": "boolean", - /** Include the admin password hash */ + /** Include the user password hash */ "includePasswordHash?": "boolean", }); @@ -55,7 +55,7 @@ export const fullExportDto = describeRoute({ }, }, 401: { - description: "Password required for sensitive export options", + description: "Password required for export or authentication failed", content: { "application/json": { schema: resolver(errorResponseSchema), From ded9d935ce5b22ce18dd0a9885da9056f80af5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Tue, 16 Dec 2025 13:07:00 +0100 Subject: [PATCH 37/49] Merge main into config-export-feature --- .github/copilot-instructions.md | 1 + .github/workflows/release.yml | 4 +- AGENTS.md | 267 ++++++ Dockerfile | 6 +- README.md | 10 +- .../api-client/@tanstack/react-query.gen.ts | 57 +- app/client/api-client/sdk.gen.ts | 36 +- app/client/api-client/types.gen.ts | 340 +++++++- .../components/create-repository-form.tsx | 784 ----------------- app/client/components/create-volume-form.tsx | 593 ------------- app/client/components/restore-form.tsx | 1 + app/client/components/snapshots-table.tsx | 160 ++-- app/client/components/volume-icon.tsx | 6 + app/client/hooks/use-server-events.ts | 33 +- .../components/create-schedule-form.tsx | 138 ++- .../components/schedule-mirrors-config.tsx | 352 ++++++++ .../schedule-notifications-config.tsx | 19 +- .../backups/components/schedule-summary.tsx | 28 +- .../components/snapshot-file-browser.tsx | 4 +- .../modules/backups/routes/backup-details.tsx | 33 +- app/client/modules/backups/routes/backups.tsx | 23 +- .../modules/backups/routes/create-backup.tsx | 5 +- .../components/create-notification-form.tsx | 4 +- .../routes/create-notification.tsx | 3 +- .../routes/notification-details.tsx | 14 +- .../notifications/routes/notifications.tsx | 2 - .../components/create-repository-form.tsx | 275 ++++++ .../azure-repository-form.tsx | 78 ++ .../repository-forms/gcs-repository-form.tsx | 65 ++ .../components/repository-forms/index.ts | 8 + .../local-repository-form.tsx | 103 +++ .../repository-forms/r2-repository-form.tsx | 80 ++ .../rclone-repository-form.tsx | 96 ++ .../repository-forms/rest-repository-form.tsx | 78 ++ .../repository-forms/s3-repository-form.tsx | 78 ++ .../repository-forms/sftp-repository-form.tsx | 101 +++ .../repositories/routes/create-repository.tsx | 10 +- .../repositories/routes/repositories.tsx | 4 +- .../routes/repository-details.tsx | 20 +- app/client/modules/repositories/tabs/info.tsx | 9 +- .../modules/repositories/tabs/snapshots.tsx | 13 +- .../modules/settings/routes/settings.tsx | 5 +- .../volumes/components/create-volume-form.tsx | 218 +++++ .../volumes/components/healthchecks-card.tsx | 3 +- .../volume-forms/directory-form.tsx | 51 ++ .../volumes/components/volume-forms/index.ts | 5 + .../components/volume-forms/nfs-form.tsx | 120 +++ .../components/volume-forms/rclone-form.tsx | 127 +++ .../components/volume-forms/smb-form.tsx | 163 ++++ .../components/volume-forms/webdav-form.tsx | 146 ++++ .../modules/volumes/routes/create-volume.tsx | 5 +- .../modules/volumes/routes/volume-details.tsx | 5 +- app/client/modules/volumes/routes/volumes.tsx | 2 - app/client/modules/volumes/tabs/info.tsx | 8 +- app/drizzle/0018_breezy_invaders.sql | 139 +++ app/drizzle/0019_secret_nomad.sql | 24 + app/drizzle/0020_even_dexter_bennett.sql | 1 + app/drizzle/0021_steady_viper.sql | 1 + app/drizzle/meta/0018_snapshot.json | 792 +++++++++++++++++ app/drizzle/meta/0019_snapshot.json | 807 +++++++++++++++++ app/drizzle/meta/0020_snapshot.json | 815 +++++++++++++++++ app/drizzle/meta/0021_snapshot.json | 823 ++++++++++++++++++ app/drizzle/meta/_journal.json | 290 +++--- app/schemas/notifications.ts | 4 +- app/schemas/volumes.ts | 10 +- app/server/core/capabilities.ts | 16 +- app/server/core/config.ts | 4 +- app/server/core/events.ts | 8 + app/server/core/repository-mutex.ts | 180 ++++ app/server/db/db.ts | 4 +- app/server/db/schema.ts | 42 +- app/server/jobs/repository-healthchecks.ts | 6 + app/server/modules/backends/backend.ts | 4 + .../modules/backends/rclone/rclone-backend.ts | 128 +++ .../modules/backends/smb/smb-backend.ts | 5 +- .../modules/backends/webdav/webdav-backend.ts | 4 +- .../modules/backups/backups.controller.ts | 28 +- app/server/modules/backups/backups.dto.ts | 91 ++ app/server/modules/backups/backups.service.ts | 280 +++++- .../modules/events/events.controller.ts | 24 + app/server/modules/lifecycle/startup.ts | 2 +- .../modules/notifications/builders/email.ts | 7 +- .../notifications/notifications.dto.ts | 2 + .../notifications/notifications.service.ts | 8 +- .../repositories/repositories.controller.ts | 2 + .../modules/repositories/repositories.dto.ts | 1 + .../repositories/repositories.service.ts | 192 ++-- app/server/modules/volumes/volume.service.ts | 30 +- app/server/utils/backend-compatibility.ts | 148 ++++ app/server/utils/crypto.ts | 10 +- app/server/utils/logger.ts | 6 +- app/server/utils/restic.ts | 112 ++- biome.json | 3 +- bun.lock | 285 ++---- docker-compose.yml | 1 + package.json | 72 +- 96 files changed, 8126 insertions(+), 2084 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md delete mode 100644 app/client/components/create-repository-form.tsx delete mode 100644 app/client/components/create-volume-form.tsx create mode 100644 app/client/modules/backups/components/schedule-mirrors-config.tsx create mode 100644 app/client/modules/repositories/components/create-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/gcs-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/index.ts create mode 100644 app/client/modules/repositories/components/repository-forms/local-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/rclone-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx create mode 100644 app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx create mode 100644 app/client/modules/volumes/components/create-volume-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/directory-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/index.ts create mode 100644 app/client/modules/volumes/components/volume-forms/nfs-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/rclone-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/smb-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/webdav-form.tsx create mode 100644 app/drizzle/0018_breezy_invaders.sql create mode 100644 app/drizzle/0019_secret_nomad.sql create mode 100644 app/drizzle/0020_even_dexter_bennett.sql create mode 100644 app/drizzle/0021_steady_viper.sql create mode 100644 app/drizzle/meta/0018_snapshot.json create mode 100644 app/drizzle/meta/0019_snapshot.json create mode 100644 app/drizzle/meta/0020_snapshot.json create mode 100644 app/drizzle/meta/0021_snapshot.json create mode 100644 app/server/core/repository-mutex.ts create mode 100644 app/server/modules/backends/rclone/rclone-backend.ts create mode 100644 app/server/utils/backend-compatibility.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..11d4b9cf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +- This project uses the AGENTS.md file to give detailed information about the repository structure and development commands. Make sure to read this file before starting development. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16e8d9a0..dadfa1b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,8 +62,6 @@ jobs: type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }} flavor: | latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} - cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache - cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max - name: Build and push images uses: docker/build-push-action@v6 @@ -76,6 +74,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} + cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache + cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max publish-release: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..136692d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,267 @@ +# AGENTS.md + +## Important instructions + +- Never create migration files manually. Always use the provided command to generate migrations +- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration + +## Project Overview + +Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). + +## Technology Stack + +- **Runtime**: Bun 1.3.1 +- **Server**: Hono (web framework) with Bun runtime +- **Client**: React Router v7 (SSR) with React 19 +- **Database**: SQLite with Drizzle ORM +- **Validation**: ArkType for runtime schema validation +- **Styling**: Tailwind CSS v4 + Radix UI components +- **Architecture**: Unified application structure (not a monorepo) +- **Code Quality**: Biome (formatter & linter) +- **Containerization**: Docker with multi-stage builds + +## Repository Structure + +This is a unified application with the following structure: + +- `app/server` - Bun-based API server with Hono +- `app/client` - React Router SSR frontend components and modules +- `app/schemas` - Shared ArkType schemas for validation +- `app/drizzle` - Database migrations + +### Type Checking + +```bash +# Run type checking and generate React Router types +bun run tsc +``` + +### Building + +```bash +# Build for production +bun run build +``` + +### Database Migrations + +```bash +# Generate new migration from schema changes +bun gen:migrations + +# Generate a custom empty migration +bunx drizzle-kit generate --custom --name=fix-timestamps-to-ms + +``` + +### API Client Generation + +```bash +# Generate TypeScript API client from OpenAPI spec +# Note: Server is always running don't need to start it separately +bun run gen:api-client +``` + +### Code Quality + +```bash +# Format and lint (Biome) +bunx biome check --write . + +# Format only +bunx biome format --write . + +# Lint only +bunx biome lint . +``` + +## Architecture + +### Server Architecture + +The server follows a modular service-oriented architecture: + +**Entry Point**: `app/server/index.ts` + +- Initializes servers using `react-router-hono-server`: + 1. Main API server on port 4096 (REST API + serves static frontend) + 2. Docker volume plugin server on Unix socket `/run/docker/plugins/zerobyte.sock` (optional, if Docker is available) + +**Modules** (`app/server/modules/`): +Each module follows a controller � service � database pattern: + +- `auth/` - User authentication and session management +- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories) +- `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone) +- `backups/` - Backup schedule management and execution +- `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover) +- `driver/` - Docker volume plugin implementation +- `events/` - Server-Sent Events for real-time updates +- `system/` - System information and capabilities +- `lifecycle/` - Application startup/shutdown hooks + +**Backends** (`app/server/modules/backends/`): +Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2). + +**Jobs** (`app/server/jobs/`): +Cron-based background jobs managed by the Scheduler: + +- `backup-execution.ts` - Runs scheduled backups (every minute) +- `cleanup-dangling.ts` - Removes stale mounts (hourly) +- `healthchecks.ts` - Checks volume health (every 5 minutes) +- `repository-healthchecks.ts` - Validates repositories (every 10 minutes) +- `cleanup-sessions.ts` - Expires old sessions (daily) + +**Core** (`app/server/core/`): + +- `scheduler.ts` - Job scheduling system using node-cron +- `capabilities.ts` - Detects available system features (Docker support, etc.) +- `constants.ts` - Application-wide constants + +**Utils** (`app/server/utils/`): + +- `restic.ts` - Restic CLI wrapper with type-safe output parsing +- `spawn.ts` - Safe subprocess execution helpers +- `logger.ts` - Winston-based logging +- `crypto.ts` - Encryption utilities +- `errors.ts` - Error handling middleware + +**Database** (`app/server/db/`): + +- Uses Drizzle ORM with SQLite +- Schema in `schema.ts` defines: volumes, repositories, backup schedules, notifications, users, sessions +- Migrations: `app/drizzle/` + +### Client Architecture + +**Framework**: React Router v7 with SSR +**Entry Point**: `app/root.tsx` + +The client uses: + +- TanStack Query for server state management +- Auto-generated API client from OpenAPI spec (in `app/client/api-client/`) +- Radix UI primitives with custom Tailwind styling +- Server-Sent Events hook (`use-server-events.ts`) for real-time updates + +Routes are organized in feature modules at `app/client/modules/*/routes/`. + +### Shared Schemas + +`app/schemas/` contains ArkType schemas used by both client and server: + +- Volume configurations (NFS, SMB, WebDAV, directory) +- Repository configurations (S3, Azure, GCS, local, rclone) +- Restic command output parsing types +- Backend status types + +These schemas provide runtime validation and TypeScript types. + +## Restic Integration + +Zerobyte is a wrapper around Restic for backup operations. Key integration points: + +**Repository Management**: + +- Creates/initializes Restic repositories via `restic init` +- Supports multiple backends: local, S3, Azure Blob Storage, Google Cloud Storage, or any rclone-supported backend +- Stores single encryption password in `/var/lib/zerobyte/restic/password` (auto-generated on first run) + +**Backup Operations**: + +- Executes `restic backup` with user-defined schedules (cron expressions) +- Supports include/exclude patterns for selective backups +- Parses JSON output for progress tracking and statistics +- Implements retention policies via `restic forget --prune` + +**Repository Utilities** (`utils/restic.ts`): + +- `buildRepoUrl()` - Constructs repository URLs for different backends +- `buildEnv()` - Sets environment variables (credentials, cache dir) +- `ensurePassfile()` - Manages encryption password file +- Type-safe parsing of Restic JSON output using ArkType schemas + +**Rclone Integration** (`app/server/modules/repositories/`): + +- Allows using any rclone backend as a Restic repository +- Dynamically generates rclone config and passes via environment variables +- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc. + +## Docker Volume Plugin + +When Docker socket is available (`/var/run/docker.sock`), Zerobyte registers as a Docker volume plugin: + +**Plugin Location**: `/run/docker/plugins/zerobyte.sock` +**Implementation**: `app/server/modules/driver/driver.controller.ts` + +This allows other containers to mount Zerobyte volumes using Docker. + +The plugin implements the Docker Volume Plugin API v1. + +## Environment & Configuration + +**Runtime Environment Variables**: + +- Database path: `./data/zerobyte.db` (configurable via `drizzle.config.ts`) +- Restic cache: `/var/lib/zerobyte/restic/cache` +- Restic password: `/var/lib/zerobyte/restic/password` +- Volume mounts: `/var/lib/zerobyte/mounts/` +- Local repositories: `/var/lib/zerobyte/repositories/` + +**Capabilities Detection**: +On startup, the server detects available capabilities (see `core/capabilities.ts`): + +- **Docker**: Requires `/var/run/docker.sock` access +- System will gracefully degrade if capabilities are unavailable + +## Common Workflows + +### Adding a New Volume Backend + +1. Create backend implementation in `app/server/modules/backends//` +2. Implement `mount()` and `unmount()` methods +3. Add schema to `app/schemas/volumes.ts` +4. Update `volumeConfigSchema` discriminated union +5. Update backend factory in `app/server/modules/backends/backend.ts` + +### Adding a New Repository Backend + +1. Add backend type to `app/schemas/restic.ts` +2. Update `buildRepoUrl()` in `app/server/utils/restic.ts` +3. Update `buildEnv()` to handle credentials/configuration +4. Add DTO schemas in `app/server/modules/repositories/repositories.dto.ts` +5. Update repository service to handle new backend + +### Adding a New Scheduled Job + +1. Create job class in `app/server/jobs/.ts` extending `Job` +2. Implement `run()` method +3. Register in `app/server/modules/lifecycle/startup.ts` with cron expression: + ```typescript + Scheduler.build(YourJob).schedule("* * * * *"); + ``` + +## Important Notes + +- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes +- **Imports**: Organize imports is disabled in Biome - do not auto-organize +- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun +- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase +- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings +- **Security**: Restic password file has 0600 permissions - never expose it +- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts +- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs` + +## Docker Development Setup + +The `docker-compose.yml` defines two services: + +- `zerobyte-dev` - Development with hot reload (uses `development` stage) +- `zerobyte-prod` - Production build (uses `production` stage) + +Both mount: + +- `/var/lib/zerobyte` for persistent data +- `/dev/fuse` device for FUSE mounting +- Optionally `/var/run/docker.sock` for Docker plugin functionality diff --git a/Dockerfile b/Dockerfile index 52676669..206d294b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -ARG BUN_VERSION="1.3.1" +ARG BUN_VERSION="1.3.3" FROM oven/bun:${BUN_VERSION}-alpine AS base -RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client +RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 # ------------------------------ @@ -14,7 +14,7 @@ WORKDIR /deps ARG TARGETARCH ARG RESTIC_VERSION="0.18.1" -ARG SHOUTRRR_VERSION="0.12.0" +ARG SHOUTRRR_VERSION="0.12.1" ENV TARGETARCH=${TARGETARCH} RUN apk add --no-cache curl bzip2 unzip tar diff --git a/README.md b/README.md index f9838551..226a38f9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.13 + image: ghcr.io/nicotsx/zerobyte:v0.18 container_name: zerobyte restart: unless-stopped cap_add: @@ -78,7 +78,7 @@ If you want to track a local directory on the same server where Zerobyte is runn ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.13 + image: ghcr.io/nicotsx/zerobyte:v0.18 container_name: zerobyte restart: unless-stopped cap_add: @@ -146,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.13 + image: ghcr.io/nicotsx/zerobyte:v0.18 container_name: zerobyte restart: unless-stopped cap_add: @@ -223,7 +223,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.13 + image: ghcr.io/nicotsx/zerobyte:v0.18 container_name: zerobyte restart: unless-stopped ports: @@ -254,7 +254,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.13 + image: ghcr.io/nicotsx/zerobyte:v0.18 container_name: zerobyte restart: unless-stopped cap_add: diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index fab5ce72..83ae179c 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, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, 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, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, 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, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, 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, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, 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, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, 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 @@ -755,6 +755,59 @@ export const updateScheduleNotificationsMutation = (options?: Partial) => createQueryKey("getScheduleMirrors", options); + +/** + * Get mirror repository assignments for a backup schedule + */ +export const getScheduleMirrorsOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getScheduleMirrors({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getScheduleMirrorsQueryKey(options) +}); + +/** + * Update mirror repository assignments for a backup schedule + */ +export const updateScheduleMirrorsMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateScheduleMirrors({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getMirrorCompatibilityQueryKey = (options: Options) => createQueryKey("getMirrorCompatibility", options); + +/** + * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository + */ +export const getMirrorCompatibilityOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getMirrorCompatibility({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getMirrorCompatibilityQueryKey(options) +}); + export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey("listNotificationDestinations", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 7778eb87..6d83b761 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, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, 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, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, 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, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, 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, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, 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 & { /** @@ -476,6 +476,40 @@ export const updateScheduleNotifications = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/backups/{scheduleId}/mirrors', + ...options + }); +}; + +/** + * Update mirror repository assignments for a backup schedule + */ +export const updateScheduleMirrors = (options: Options) => { + return (options.client ?? client).put({ + url: '/api/v1/backups/{scheduleId}/mirrors', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository + */ +export const getMirrorCompatibility = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', + ...options + }); +}; + /** * List all notification destinations */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index d196feef..3b0fb9bb 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -164,6 +164,11 @@ export type ListVolumesResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -191,7 +196,7 @@ export type ListVolumesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }>; }; @@ -211,6 +216,11 @@ export type CreateVolumeData = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -255,6 +265,11 @@ export type CreateVolumeResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -282,7 +297,7 @@ export type CreateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -302,6 +317,11 @@ export type TestConnectionData = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -399,6 +419,11 @@ export type GetVolumeResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -426,7 +451,7 @@ export type GetVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -448,6 +473,11 @@ export type UpdateVolumeData = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -501,6 +531,11 @@ export type UpdateVolumeResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -528,7 +563,7 @@ export type UpdateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -1124,6 +1159,7 @@ export type ListSnapshotsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }>; }; @@ -1170,6 +1206,7 @@ export type GetSnapshotDetailsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }; }; @@ -1289,12 +1326,14 @@ export type ListBackupSchedulesResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1393,6 +1432,11 @@ export type ListBackupSchedulesResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1420,7 +1464,7 @@ export type ListBackupSchedulesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1433,8 +1477,10 @@ export type CreateBackupScheduleData = { body?: { cronExpression: string; enabled: boolean; + name: string; repositoryId: string; volumeId: number; + excludeIfPresent?: Array; excludePatterns?: Array; includePatterns?: Array; retentionPolicy?: { @@ -1461,12 +1507,14 @@ export type CreateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1522,12 +1570,14 @@ export type GetBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1626,6 +1676,11 @@ export type GetBackupScheduleResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1653,7 +1708,7 @@ export type GetBackupScheduleResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1667,8 +1722,10 @@ export type UpdateBackupScheduleData = { cronExpression: string; repositoryId: string; enabled?: boolean; + excludeIfPresent?: Array; excludePatterns?: Array; includePatterns?: Array; + name?: string; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1695,12 +1752,14 @@ export type UpdateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1736,12 +1795,14 @@ export type GetBackupScheduleForVolumeResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1840,6 +1901,11 @@ export type GetBackupScheduleForVolumeResponses = { version: '3' | '4' | '4.1'; port?: number; readOnly?: boolean; + } | { + backend: 'rclone'; + path: string; + remote: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1867,7 +1933,7 @@ export type GetBackupScheduleForVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1971,13 +2037,13 @@ export type GetScheduleNotificationsResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2018,6 +2084,7 @@ export type GetScheduleNotificationsResponses = { notifyOnFailure: boolean; notifyOnStart: boolean; notifyOnSuccess: boolean; + notifyOnWarning: boolean; scheduleId: number; }>; }; @@ -2031,6 +2098,7 @@ export type UpdateScheduleNotificationsData = { notifyOnFailure: boolean; notifyOnStart: boolean; notifyOnSuccess: boolean; + notifyOnWarning: boolean; }>; }; path: { @@ -2059,13 +2127,13 @@ export type UpdateScheduleNotificationsResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2106,12 +2174,238 @@ export type UpdateScheduleNotificationsResponses = { notifyOnFailure: boolean; notifyOnStart: boolean; notifyOnSuccess: boolean; + notifyOnWarning: boolean; scheduleId: number; }>; }; export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses]; +export type GetScheduleMirrorsData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type GetScheduleMirrorsResponses = { + /** + * List of mirror repository assignments for the schedule + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type GetScheduleMirrorsResponse = GetScheduleMirrorsResponses[keyof GetScheduleMirrorsResponses]; + +export type UpdateScheduleMirrorsData = { + body?: { + mirrors: Array<{ + enabled: boolean; + repositoryId: string; + }>; + }; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type UpdateScheduleMirrorsResponses = { + /** + * Mirror assignments updated successfully + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses]; + +export type GetMirrorCompatibilityData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors/compatibility'; +}; + +export type GetMirrorCompatibilityResponses = { + /** + * List of repositories with their mirror compatibility status + */ + 200: Array<{ + compatible: boolean; + reason: string | null; + repositoryId: string; + }>; +}; + +export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses]; + export type ListNotificationDestinationsData = { body?: never; path?: never; @@ -2136,13 +2430,13 @@ export type ListNotificationDestinationsResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2197,13 +2491,13 @@ export type CreateNotificationDestinationData = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2257,13 +2551,13 @@ export type CreateNotificationDestinationResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2364,13 +2658,13 @@ export type GetNotificationDestinationResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2425,13 +2719,13 @@ export type UpdateNotificationDestinationData = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; @@ -2495,13 +2789,13 @@ export type UpdateNotificationDestinationResponses = { type: 'telegram'; } | { from: string; - password: string; smtpHost: string; smtpPort: number; to: Array; type: 'email'; useTLS: boolean; - username: string; + password?: string; + username?: string; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; diff --git a/app/client/components/create-repository-form.tsx b/app/client/components/create-repository-form.tsx deleted file mode 100644 index 255dd7a1..00000000 --- a/app/client/components/create-repository-form.tsx +++ /dev/null @@ -1,784 +0,0 @@ -import { arktypeResolver } from "@hookform/resolvers/arktype"; -import { type } from "arktype"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { cn, slugify } from "~/client/lib/utils"; -import { deepClean } from "~/utils/object"; -import { Button } from "./ui/button"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; -import { Input } from "./ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -import { useQuery } from "@tanstack/react-query"; -import { Alert, AlertDescription } from "./ui/alert"; -import { ExternalLink, AlertTriangle } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -import { useSystemInfo } from "~/client/hooks/use-system-info"; -import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic"; -import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen"; -import { Checkbox } from "./ui/checkbox"; -import { DirectoryBrowser } from "./directory-browser"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "./ui/alert-dialog"; -import { Textarea } from "./ui/textarea"; - -export const formSchema = type({ - name: "2<=string<=32", - compressionMode: type.valueOf(COMPRESSION_MODES).optional(), -}).and(repositoryConfigSchema); -const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); - -export type RepositoryFormValues = typeof formSchema.inferIn; - -type Props = { - onSubmit: (values: RepositoryFormValues) => void; - mode?: "create" | "update"; - initialValues?: Partial; - formId?: string; - loading?: boolean; - className?: string; -}; - -const defaultValuesForType = { - local: { backend: "local" as const, compressionMode: "auto" as const }, - s3: { backend: "s3" as const, compressionMode: "auto" as const }, - r2: { backend: "r2" as const, compressionMode: "auto" as const }, - gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, - azure: { backend: "azure" as const, compressionMode: "auto" as const }, - rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, - rest: { backend: "rest" as const, compressionMode: "auto" as const }, - sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 }, -}; - -export const CreateRepositoryForm = ({ - onSubmit, - mode = "create", - initialValues, - formId, - loading, - className, -}: Props) => { - const form = useForm({ - resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), - defaultValues: initialValues, - resetOptions: { - keepDefaultValues: true, - keepDirtyValues: false, - }, - }); - - const { watch, setValue } = form; - - const watchedBackend = watch("backend"); - const watchedIsExistingRepository = watch("isExistingRepository"); - - const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default"); - const [showPathBrowser, setShowPathBrowser] = useState(false); - const [showPathWarning, setShowPathWarning] = useState(false); - - const { capabilities } = useSystemInfo(); - - const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({ - ...listRcloneRemotesOptions(), - enabled: capabilities.rclone, - }); - - useEffect(() => { - form.reset({ - name: form.getValues().name, - isExistingRepository: form.getValues().isExistingRepository, - customPassword: form.getValues().customPassword, - ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType], - }); - }, [watchedBackend, form]); - - return ( - - - ( - - Name - - field.onChange(slugify(e.target.value))} - max={32} - min={2} - /> - - Unique identifier for the repository. - - - )} - /> - ( - - Backend - - Choose the storage backend for this repository. - - - )} - /> - - ( - - Compression Mode - - Compression mode for backups stored in this repository. - - - )} - /> - - ( - - - { - field.onChange(checked); - if (!checked) { - setPasswordMode("default"); - setValue("customPassword", undefined); - } - }} - /> - -
- Import existing repository - Check this if the repository already exists at the specified location -
-
- )} - /> - {watchedIsExistingRepository && ( - <> - - Repository Password - - - Choose whether to use Zerobyte's master password or enter a custom password for the existing repository. - - - - {passwordMode === "custom" && ( - ( - - Repository Password - - - - - The password used to encrypt this repository. It will be stored securely. - - - - )} - /> - )} - - )} - - {watchedBackend === "local" && ( - <> - - Repository Directory -
-
- {form.watch("path") || "/var/lib/zerobyte/repositories"} -
- -
- The directory where the repository will be stored. -
- - - - - - - Important: Host Mount Required - - -

When selecting a custom path, ensure it is mounted from the host machine into the container.

-

- If the path is not a host mount, you will lose your repository data when the container restarts. -

-

- The default path /var/lib/zerobyte/repositories is - already mounted from the host and is safe to use. -

-
-
- - Cancel - { - setShowPathBrowser(true); - setShowPathWarning(false); - }} - > - I Understand, Continue - - -
-
- - - - - Select Repository Directory - - Choose a directory from the filesystem to store the repository. - - -
- form.setValue("path", path)} - selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"} - /> -
- - Cancel - setShowPathBrowser(false)}>Done - -
-
- - )} - - {watchedBackend === "s3" && ( - <> - ( - - Endpoint - - - - S3-compatible endpoint URL. - - - )} - /> - ( - - Bucket - - - - S3 bucket name for storing backups. - - - )} - /> - ( - - Access Key ID - - - - S3 access key ID for authentication. - - - )} - /> - ( - - Secret Access Key - - - - S3 secret access key for authentication. - - - )} - /> - - )} - - {watchedBackend === "r2" && ( - <> - ( - - Endpoint - - - - - R2 endpoint (without https://). Find in R2 dashboard under bucket settings. - - - - )} - /> - ( - - Bucket - - - - R2 bucket name for storing backups. - - - )} - /> - ( - - Access Key ID - - - - R2 API token Access Key ID (create in Cloudflare R2 dashboard). - - - )} - /> - ( - - Secret Access Key - - - - R2 API token Secret Access Key (shown once when creating token). - - - )} - /> - - )} - - {watchedBackend === "gcs" && ( - <> - ( - - Bucket - - - - GCS bucket name for storing backups. - - - )} - /> - ( - - Project ID - - - - Google Cloud project ID. - - - )} - /> - ( - - Service Account JSON - - - - Service account JSON credentials for authentication. - - - )} - /> - - )} - - {watchedBackend === "azure" && ( - <> - ( - - Container - - - - Azure Blob Storage container name for storing backups. - - - )} - /> - ( - - Account Name - - - - Azure Storage account name. - - - )} - /> - ( - - Account Key - - - - Azure Storage account key for authentication. - - - )} - /> - ( - - Endpoint Suffix (Optional) - - - - Custom Azure endpoint suffix (defaults to core.windows.net). - - - )} - /> - - )} - - {watchedBackend === "rclone" && - (!rcloneRemotes || rcloneRemotes.length === 0 ? ( - - -

No rclone remotes configured

-

- To use rclone, you need to configure remotes on your host system -

- - View rclone documentation - - -
-
- ) : ( - <> - ( - - Remote - - Select the rclone remote configured on your host system. - - - )} - /> - ( - - Path - - - - Path within the remote where backups will be stored. - - - )} - /> - - ))} - - {watchedBackend === "rest" && ( - <> - ( - - REST Server URL - - - - URL of the REST server. - - - )} - /> - ( - - Repository Path (Optional) - - - - Path to the repository on the REST server (leave empty for root). - - - )} - /> - ( - - Username (Optional) - - - - Username for REST server authentication. - - - )} - /> - ( - - Password (Optional) - - - - Password for REST server authentication. - - - )} - /> - - )} - - {watchedBackend === "sftp" && ( - <> - ( - - Host - - - - SFTP server hostname or IP address. - - - )} - /> - ( - - Port - - field.onChange(parseInt(e.target.value, 10))} - /> - - SSH port (default: 22). - - - )} - /> - ( - - User - - - - SSH username for authentication. - - - )} - /> - ( - - Path - - - - Repository path on the SFTP server. - - - )} - /> - ( - - SSH Private Key - -