From 5c67f2e665aa88a4efbfd2bd8731967c8b718abf Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 21 Feb 2026 09:51:21 +0100 Subject: [PATCH] fix: dump single file no tar --- .../api-client/@tanstack/react-query.gen.ts | 22 +++++ app/client/api-client/index.ts | 4 + app/client/api-client/sdk.gen.ts | 11 +++ app/client/api-client/types.gen.ts | 22 +++++ .../file-browsers/snapshot-tree-browser.tsx | 52 +++++++--- app/client/components/restore-form.tsx | 7 +- .../__tests__/repositories.service.test.ts | 97 ++++++++++++++++++- .../repositories/repositories.controller.ts | 6 +- .../modules/repositories/repositories.dto.ts | 18 +++- .../repositories/repositories.service.ts | 33 +++++-- app/server/utils/restic.ts | 7 +- 11 files changed, 249 insertions(+), 30 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 686355de..bbad7cc8 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -25,6 +25,7 @@ import { deleteVolume, devPanelExec, downloadResticPassword, + dumpSnapshot, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, @@ -100,6 +101,8 @@ import type { DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, + DumpSnapshotData, + DumpSnapshotResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, @@ -851,6 +854,25 @@ export const listSnapshotFilesInfiniteOptions = (options: Options) => createQueryKey("dumpSnapshot", options); + +/** + * Download a snapshot path as a tar archive (folders) or raw file stream (single files) + */ +export const dumpSnapshotOptions = (options: Options) => + queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await dumpSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: dumpSnapshotQueryKey(options), + }); + /** * Restore a snapshot to a target path on the filesystem */ diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 874dad9d..a2851cf5 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -16,6 +16,7 @@ export { deleteVolume, devPanelExec, downloadResticPassword, + dumpSnapshot, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, @@ -109,6 +110,9 @@ export type { DownloadResticPasswordData, DownloadResticPasswordResponse, DownloadResticPasswordResponses, + DumpSnapshotData, + DumpSnapshotResponse, + DumpSnapshotResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 0c028616..87103d93 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -35,6 +35,8 @@ import type { DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, + DumpSnapshotData, + DumpSnapshotResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, @@ -431,6 +433,15 @@ export const listSnapshotFiles = ( ...options, }); +/** + * Download a snapshot path as a tar archive (folders) or raw file stream (single files) + */ +export const dumpSnapshot = (options: Options) => + (options.client ?? client).get({ + url: "/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump", + ...options, + }); + /** * Restore a snapshot to a target path on the filesystem */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 4b68b145..71c57dba 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1970,6 +1970,28 @@ export type ListSnapshotFilesResponses = { export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses]; +export type DumpSnapshotData = { + body?: never; + path: { + shortId: string; + snapshotId: string; + }; + query?: { + kind?: "dir" | "file"; + path?: string; + }; + url: "/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump"; +}; + +export type DumpSnapshotResponses = { + /** + * Snapshot content stream + */ + 200: Blob | File; +}; + +export type DumpSnapshotResponse = DumpSnapshotResponses[keyof DumpSnapshotResponses]; + export type RestoreSnapshotData = { body?: { snapshotId: string; diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index b4aa69bb..6bc178da 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -12,6 +12,7 @@ type SnapshotTreeBrowserProps = FileBrowserUiProps & { basePath?: string; pageSize?: number; enabled?: boolean; + onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void; }; export const SnapshotTreeBrowser = ({ @@ -22,7 +23,7 @@ export const SnapshotTreeBrowser = ({ enabled = true, ...uiProps }: SnapshotTreeBrowserProps) => { - const { selectedPaths, onSelectionChange, ...fileBrowserUiProps } = uiProps; + const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps; const queryClient = useQueryClient(); const normalizedBasePath = normalizeAbsolutePath(basePath); @@ -66,20 +67,6 @@ export const SnapshotTreeBrowser = ({ return displayPaths; }, [selectedPaths, stripBasePath]); - const handleSelectionChange = useCallback( - (nextDisplayPaths: Set) => { - if (!onSelectionChange) return; - - const nextFullPaths = new Set(); - for (const displayPath of nextDisplayPaths) { - nextFullPaths.add(addBasePath(displayPath)); - } - - onSelectionChange(nextFullPaths); - }, - [onSelectionChange, addBasePath], - ); - const fileBrowser = useFileBrowser({ initialData: data, isLoading, @@ -113,6 +100,41 @@ export const SnapshotTreeBrowser = ({ }, }); + const displayPathKinds = useMemo(() => { + const kinds = new Map(); + for (const entry of fileBrowser.fileArray) { + kinds.set(entry.path, entry.type === "file" ? "file" : "dir"); + } + return kinds; + }, [fileBrowser.fileArray]); + + const handleSelectionChange = useCallback( + (nextDisplayPaths: Set) => { + if (!onSelectionChange) return; + + const nextFullPaths = new Set(); + for (const displayPath of nextDisplayPaths) { + nextFullPaths.add(addBasePath(displayPath)); + } + + if (onSingleSelectionKindChange) { + if (nextDisplayPaths.size === 1) { + const [selectedDisplayPath] = nextDisplayPaths; + if (selectedDisplayPath) { + onSingleSelectionKindChange(displayPathKinds.get(selectedDisplayPath) ?? null); + } else { + onSingleSelectionKindChange(null); + } + } else { + onSingleSelectionKindChange(null); + } + } + + onSelectionChange(nextFullPaths); + }, + [onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds], + ); + const errorDetails = parseError(error)?.message; const errorMessage = errorDetails ? `Failed to load files: ${errorDetails}` diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index ab9e4fe1..add9f38e 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -54,6 +54,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re const restoreCompletedRef = useRef(false); const [selectedPaths, setSelectedPaths] = useState>(new Set()); + const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null); useEffect(() => { const abortController = new AbortController(); @@ -165,6 +166,9 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re const [selectedPath] = selectedPaths; if (selectedPath) { dumpUrl.searchParams.set("path", selectedPath); + if (selectedPathKind) { + dumpUrl.searchParams.set("kind", selectedPathKind); + } } } @@ -174,7 +178,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re link.click(); document.body.removeChild(link); setWaitingForDownload(false); - }, [repository.shortId, snapshotId, selectedPaths]); + }, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]); const acknowledgeRestoreResult = useCallback(() => { setShowRestoreResultAlert(false); @@ -362,6 +366,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re withCheckboxes selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} + onSingleSelectionKindChange={setSelectedPathKind} stateClassName="flex-1 min-h-0" /> diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index 0559260b..079884ba 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -131,7 +131,7 @@ describe("repositoriesService.dumpSnapshot", () => { const emitSpy = spyOn(serverEvents, "emit"); await withContext({ organizationId, userId: user.id }, () => - repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`), + repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"), ); expect(dumpMock).toHaveBeenCalledTimes(1); @@ -156,6 +156,101 @@ describe("repositoriesService.dumpSnapshot", () => { ); }); + test("streams a single file directly when selected path is a file", async () => { + const { organizationId, user } = await createTestSession(); + const shortId = generateShortId(); + const basePath = "/var/lib/zerobyte/volumes/vol123/_data"; + + await db.insert(repositoriesTable).values({ + id: randomUUID(), + shortId, + name: `Repository-${randomUUID()}`, + type: "local", + config: { + backend: "local", + path: `/tmp/repository-${randomUUID()}`, + isExistingRepository: true, + }, + compressionMode: "off", + organizationId, + }); + + const snapshotsMock = mock(() => + Promise.resolve([ + { + id: "snapshot-file", + short_id: "snapshot-file", + time: new Date().toISOString(), + tree: "tree-file", + paths: [basePath], + hostname: "host", + }, + ]), + ); + spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots); + + const dumpMock = mock(() => + Promise.resolve({ + stream: Readable.from([]), + completion: Promise.resolve(), + abort: () => {}, + }), + ); + spyOn(restic, "dump").mockImplementation(dumpMock); + + const result = await withContext({ organizationId, userId: user.id }, () => + repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"), + ); + + expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-file:${basePath}`, { + organizationId, + path: "/documents/report.txt", + archive: false, + }); + expect(result.filename).toBe("report.txt"); + expect(result.contentType).toBe("application/octet-stream"); + }); + + test("rejects path downloads without a kind", async () => { + const { organizationId, user } = await createTestSession(); + const shortId = generateShortId(); + const basePath = "/var/lib/zerobyte/volumes/vol123/_data"; + + await db.insert(repositoriesTable).values({ + id: randomUUID(), + shortId, + name: `Repository-${randomUUID()}`, + type: "local", + config: { + backend: "local", + path: `/tmp/repository-${randomUUID()}`, + isExistingRepository: true, + }, + compressionMode: "off", + organizationId, + }); + + const snapshotsMock = mock(() => + Promise.resolve([ + { + id: "snapshot-no-kind", + short_id: "snapshot-no-kind", + time: new Date().toISOString(), + tree: "tree-no-kind", + paths: [basePath], + hostname: "host", + }, + ]), + ); + spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots); + + await expect( + withContext({ organizationId, userId: user.id }, () => + repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`), + ), + ).rejects.toThrow("Path kind is required when downloading a specific snapshot path"); + }); + test("downloads full snapshot relative to common ancestor when path is omitted", async () => { const { organizationId, user } = await createTestSession(); const shortId = generateShortId(); diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 9c316106..b6dc31bc 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -171,9 +171,9 @@ export const repositoriesController = new Hono() ) .get("/:shortId/snapshots/:snapshotId/dump", dumpSnapshotDto, validator("query", dumpSnapshotQuery), async (c) => { const { shortId, snapshotId } = c.req.param(); - const { path } = c.req.valid("query"); + const { path, kind } = c.req.valid("query"); - const dumpStream = await repositoriesService.dumpSnapshot(shortId, snapshotId, path); + const dumpStream = await repositoriesService.dumpSnapshot(shortId, snapshotId, path, kind); const signal = c.req.raw.signal; if (signal.aborted) { @@ -187,7 +187,7 @@ export const repositoriesController = new Hono() return new Response(webStream, { status: 200, headers: { - "Content-Type": "application/x-tar", + "Content-Type": dumpStream.contentType, "Content-Disposition": contentDisposition(dumpStream.filename || "snapshot.tar"), "X-Content-Type-Options": "nosniff", }, diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index cf4f6862..0226ddce 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -290,24 +290,36 @@ export const listSnapshotFilesDto = describeRoute({ }, }); +const DUMP_PATH_KINDS = { + file: "file", + dir: "dir", +} as const; + +export const dumpPathKindSchema = type.valueOf(DUMP_PATH_KINDS); +export type DumpPathKind = typeof dumpPathKindSchema.infer; + /** - * Download snapshot or a specific path as tar archive + * Download snapshot paths as tar archives (folders) or raw file streams (single files) */ export const dumpSnapshotQuery = type({ path: "string?", + kind: dumpPathKindSchema.optional(), }); export const dumpSnapshotDto = describeRoute({ - description: "Download a full snapshot or a single file/folder path as a tar archive", + description: "Download a snapshot path as a tar archive (folders) or raw file stream (single files)", tags: ["Repositories"], operationId: "dumpSnapshot", responses: { 200: { - description: "Snapshot archive stream", + description: "Snapshot content stream", content: { "application/x-tar": { schema: { type: "string", format: "binary" }, }, + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, }, }, }, diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index db9fee9e..5342f91e 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import nodePath from "node:path"; import { type } from "arktype"; import { and, eq } from "drizzle-orm"; import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; @@ -22,7 +23,7 @@ import { generateShortId } from "../../utils/id"; import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "../../utils/restic"; import { safeSpawn } from "../../utils/spawn"; import { backupsService } from "../backups/backups.service"; -import type { UpdateRepositoryBody } from "./repositories.dto"; +import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto"; import { REPOSITORY_BASE } from "~/server/core/constants"; import { findCommonAncestor } from "~/utils/common-ancestor"; import { prepareSnapshotDump } from "./helpers/dump"; @@ -390,7 +391,7 @@ const restoreSnapshot = async ( } }; -const dumpSnapshot = async (shortId: string, snapshotId: string, path?: string) => { +const dumpSnapshot = async (shortId: string, snapshotId: string, path?: string, kind?: DumpPathKind) => { const organizationId = getOrganizationId(); const repository = await findRepository(shortId); @@ -404,23 +405,43 @@ const dumpSnapshot = async (shortId: string, snapshotId: string, path?: string) try { const snapshot = await getSnapshotDetails(repository.shortId, snapshotId); const preparedDump = prepareSnapshotDump({ snapshotId, snapshotPaths: snapshot.paths, requestedPath: path }); - dumpStream = await restic.dump(repository.config, preparedDump.snapshotRef, { + const dumpOptions: Parameters[2] = { organizationId, path: preparedDump.path, - }); + }; + + let filename = preparedDump.filename; + let contentType = "application/x-tar"; + + if (path && preparedDump.path !== "/") { + if (!kind) { + throw new BadRequestError("Path kind is required when downloading a specific snapshot path"); + } + + if (kind === "file") { + dumpOptions.archive = false; + contentType = "application/octet-stream"; + const fileName = nodePath.posix.basename(preparedDump.path); + if (fileName) { + filename = fileName; + } + } + } + + dumpStream = await restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions); serverEvents.emit("dump:started", { organizationId, repositoryId: repository.shortId, snapshotId, path: preparedDump.path, - filename: preparedDump.filename, + filename, }); const completion = dumpStream.completion.finally(releaseLock); void completion.catch(() => {}); - return { ...dumpStream, completion, filename: preparedDump.filename }; + return { ...dumpStream, completion, filename, contentType }; } catch (error) { if (dumpStream) { dumpStream.abort(); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index b1ce330b..6b22203a 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -580,13 +580,18 @@ const dump = async ( options: { organizationId: string; path?: string; + archive?: false; }, ): Promise => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config, options.organizationId); const pathToDump = normalizeDumpPath(options.path); - const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump, "--archive", "tar"]; + const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump]; + + if (options.archive !== false) { + args.push("--archive", "tar"); + } addCommonArgs(args, env, config, { includeJson: false });