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, };