From 3bf3b22b96b3267131b73a9398484a666e5ade9f Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:53:44 +0100 Subject: [PATCH] feat: restore to custom location (#78) * feat: restore to custom location * refactor: define overwrite mode in shared schema --- app/client/api-client/types.gen.ts | 2 + .../components/snapshot-file-browser.tsx | 134 +++++++++++++++--- app/client/modules/repositories/tabs/info.tsx | 3 +- app/schemas/restic.ts | 9 ++ .../modules/repositories/repositories.dto.ts | 12 +- .../repositories/repositories.service.ts | 15 +- app/server/utils/restic.ts | 11 +- app/server/utils/sanitize.ts | 4 + 8 files changed, 157 insertions(+), 33 deletions(-) diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 7c4f50b..058f023 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1224,6 +1224,8 @@ export type RestoreSnapshotData = { exclude?: Array; excludeXattr?: Array; include?: Array; + overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never'; + targetPath?: string; }; path: { name: string; diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 8a848bd..90382ae 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,12 +1,13 @@ import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, FileIcon } from "lucide-react"; +import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react"; import { FileTree } from "~/client/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Button } from "~/client/components/ui/button"; import { Checkbox } from "~/client/components/ui/checkbox"; import { Label } from "~/client/components/ui/label"; import { Input } from "~/client/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { AlertDialog, AlertDialogAction, @@ -22,6 +23,9 @@ import type { Snapshot, Volume } from "~/client/lib/types"; import { toast } from "sonner"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; +import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; + +type RestoreLocation = "original" | "custom"; interface Props { snapshot: Snapshot; @@ -42,6 +46,9 @@ export const SnapshotFileBrowser = (props: Props) => { const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [excludeXattr, setExcludeXattr] = useState(""); + const [restoreLocation, setRestoreLocation] = useState("original"); + const [customTargetPath, setCustomTargetPath] = useState(""); + const [overwriteMode, setOverwriteMode] = useState("always"); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; @@ -127,6 +134,9 @@ export const SnapshotFileBrowser = (props: Props) => { .map((s) => s.trim()) .filter(Boolean); + const isCustomLocation = restoreLocation === "custom"; + const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; + restoreSnapshot({ path: { name: repositoryName }, body: { @@ -134,11 +144,24 @@ export const SnapshotFileBrowser = (props: Props) => { include: includePaths, delete: deleteExtraFiles, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, + targetPath, + overwrite: overwriteMode, }, }); setShowRestoreDialog(false); - }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]); + }, [ + selectedPaths, + addBasePath, + repositoryName, + snapshot.short_id, + restoreSnapshot, + deleteExtraFiles, + excludeXattr, + restoreLocation, + customTargetPath, + overwriteMode, + ]); return (
@@ -221,17 +244,77 @@ export const SnapshotFileBrowser = (props: Props) => { - + Confirm Restore {selectedPaths.size > 0 ? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.` - : "This will restore everything from the snapshot."}{" "} - Existing files will be overwritten by what's in the snapshot. This action cannot be undone. + : "This will restore everything from the snapshot."}
+
+ +
+ + +
+ {restoreLocation === "custom" && ( +
+ setCustomTargetPath(e.target.value)} + /> +

Files will be restored directly to this path

+
+ )} +
+ +
+ + +

+ {overwriteMode === OVERWRITE_MODES.always && + "Existing files will always be replaced with the snapshot version."} + {overwriteMode === OVERWRITE_MODES.ifChanged && + "Files are only replaced if their content differs from the snapshot."} + {overwriteMode === OVERWRITE_MODES.ifNewer && + "Files are only replaced if the snapshot version has a newer modification time."} + {overwriteMode === OVERWRITE_MODES.never && + "Existing files will never be replaced, only missing files are restored."} +

+
+
{showAdvanced && ( -
- - setExcludeXattr(e.target.value)} - /> -

- Exclude specific extended attributes during restore (comma-separated) -

-
+
+
+ + setExcludeXattr(e.target.value)} + /> +

+ Exclude specific extended attributes during restore (comma-separated) +

+
+
setDeleteExtraFiles(checked === true)} />
@@ -274,7 +359,12 @@ export const SnapshotFileBrowser = (props: Props) => {
Cancel - Confirm + + Confirm Restore + diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index 4b4a652..585a5a1 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -21,8 +21,7 @@ import type { Repository } from "~/client/lib/types"; import { slugify } from "~/client/lib/utils"; import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen"; - -type CompressionMode = "off" | "auto" | "fastest" | "better" | "max"; +import type { CompressionMode } from "~/schemas/restic"; type Props = { repository: Repository; diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index bb13b18..e83cf2a 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -105,3 +105,12 @@ export const REPOSITORY_STATUS = { } as const; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; + +export const OVERWRITE_MODES = { + always: "always", + ifChanged: "if-changed", + ifNewer: "if-newer", + never: "never", +} as const; + +export type OverwriteMode = (typeof OVERWRITE_MODES)[keyof typeof OVERWRITE_MODES]; diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index e54af33..aa77a62 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -1,6 +1,12 @@ import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; -import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic"; +import { + COMPRESSION_MODES, + OVERWRITE_MODES, + REPOSITORY_BACKENDS, + REPOSITORY_STATUS, + repositoryConfigSchema, +} from "~/schemas/restic"; export const repositorySchema = type({ id: "string", @@ -269,12 +275,16 @@ export const listSnapshotFilesDto = describeRoute({ /** * Restore a snapshot */ +export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES); + export const restoreSnapshotBody = type({ snapshotId: "string", include: "string[]?", exclude: "string[]?", excludeXattr: "string[]?", delete: "boolean?", + targetPath: "string?", + overwrite: overwriteModeSchema.optional(), }); export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 818f015..127a827 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -8,7 +8,7 @@ import { toMessage } from "../../utils/errors"; import { generateShortId } from "../../utils/id"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; -import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; +import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic"; const listRepositories = async () => { const repositories = await db.query.repositoriesTable.findMany({}); @@ -201,7 +201,14 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string const restoreSnapshot = async ( name: string, snapshotId: string, - options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean }, + options?: { + include?: string[]; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + targetPath?: string; + overwrite?: OverwriteMode; + }, ) => { const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.name, name), @@ -211,7 +218,9 @@ const restoreSnapshot = async ( throw new NotFoundError("Repository not found"); } - const result = await restic.restore(repository.config, snapshotId, "/", options); + const target = options?.targetPath || "/"; + + const result = await restic.restore(repository.config, snapshotId, target, options); return { success: true, diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index d08dcb5..28e01ce 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -9,7 +9,7 @@ import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; import { safeSpawn } from "./spawn"; -import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; +import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic"; import { ResticError } from "./errors"; const backupOutputSchema = type({ @@ -353,7 +353,7 @@ const backup = async ( const restoreOutputSchema = type({ message_type: "'summary'", - total_files: "number", + total_files: "number?", files_restored: "number", files_skipped: "number", total_bytes: "number?", @@ -369,8 +369,8 @@ const restore = async ( include?: string[]; exclude?: string[]; excludeXattr?: string[]; - path?: string; delete?: boolean; + overwrite?: OverwriteMode; }, ) => { const repoUrl = buildRepoUrl(config); @@ -378,8 +378,8 @@ const restore = async ( const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; - if (options?.path) { - args[args.length - 4] = `${snapshotId}:${options.path}`; + if (options?.overwrite) { + args.push("--overwrite", options.overwrite); } if (options?.delete) { @@ -407,6 +407,7 @@ const restore = async ( addRepoSpecificArgs(args, config, env); args.push("--json"); + logger.debug(`Executing: restic ${args.join(" ")}`); const res = await $`restic ${args}`.env(env).nothrow(); await cleanupTemporaryKeys(config, env); diff --git a/app/server/utils/sanitize.ts b/app/server/utils/sanitize.ts index 698d716..d72e2b7 100644 --- a/app/server/utils/sanitize.ts +++ b/app/server/utils/sanitize.ts @@ -3,6 +3,10 @@ * This removes passwords and credentials from logs and error messages */ export const sanitizeSensitiveData = (text: string): string => { + if (process.env.NODE_ENV === "development") { + return text; + } + let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***"); sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");