From f4a7f6f7373db6bdd0a414c74a0e93bd3631febe Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 26 Nov 2025 23:13:34 +0100 Subject: [PATCH] feat: restore to custom location --- app/client/api-client/types.gen.ts | 2 + .../components/snapshot-file-browser.tsx | 132 +++++++++++++++--- .../modules/repositories/repositories.dto.ts | 5 + .../repositories/repositories.service.ts | 13 +- app/server/utils/restic.ts | 11 +- app/server/utils/sanitize.ts | 4 + 6 files changed, 139 insertions(+), 28 deletions(-) diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 7c4f50b8..058f023e 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 8a848bd5..efe094ea 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, @@ -23,6 +24,9 @@ import { toast } from "sonner"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; +type RestoreLocation = "original" | "custom"; +type OverwriteMode = "always" | "if-changed" | "if-newer" | "never"; + interface Props { snapshot: Snapshot; repositoryName: string; @@ -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,75 @@ 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 === "always" && "Existing files will always be replaced with the snapshot version."} + {overwriteMode === "if-changed" && + "Files are only replaced if their content differs from the snapshot."} + {overwriteMode === "if-newer" && + "Files are only replaced if the snapshot version has a newer modification time."} + {overwriteMode === "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 +357,12 @@ export const SnapshotFileBrowser = (props: Props) => {
Cancel - Confirm + + Confirm Restore + diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index e54af333..801884bd 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -269,12 +269,17 @@ export const listSnapshotFilesDto = describeRoute({ /** * Restore a snapshot */ +export const overwriteModeSchema = type("'always' | 'if-changed' | 'if-newer' | 'never'"); +export type OverwriteMode = typeof overwriteModeSchema.infer; + 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 818f0158..d5965bd4 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -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?: "always" | "if-changed" | "if-newer" | "never"; + }, ) => { 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 d08dcb5e..ec1a26d0 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -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?", @@ -361,6 +361,8 @@ const restoreOutputSchema = type({ bytes_skipped: "number", }); +type OverwriteMode = "always" | "if-changed" | "if-newer" | "never"; + const restore = async ( config: RepositoryConfig, snapshotId: string, @@ -369,8 +371,8 @@ const restore = async ( include?: string[]; exclude?: string[]; excludeXattr?: string[]; - path?: string; delete?: boolean; + overwrite?: OverwriteMode; }, ) => { const repoUrl = buildRepoUrl(config); @@ -378,8 +380,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 +409,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 698d7160..d72e2b7e 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:***@");