diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 513f51e..f673a5e 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -23,6 +23,7 @@ import { deleteRepository, getRepository, listSnapshots, + listSnapshotFiles, listBackupSchedules, createBackupSchedule, deleteBackupSchedule, @@ -67,6 +68,7 @@ import type { DeleteRepositoryResponse, GetRepositoryData, ListSnapshotsData, + ListSnapshotFilesData, ListBackupSchedulesData, CreateBackupScheduleData, CreateBackupScheduleResponse, @@ -715,6 +717,27 @@ export const listSnapshotsOptions = (options: Options) => { }); }; +export const listSnapshotFilesQueryKey = (options: Options) => + createQueryKey("listSnapshotFiles", options); + +/** + * List files and directories in a snapshot + */ +export const listSnapshotFilesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listSnapshotFiles({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: listSnapshotFilesQueryKey(options), + }); +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey("listBackupSchedules", options); diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 9795d4d..b05636e 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -50,6 +50,8 @@ import type { GetRepositoryResponses, ListSnapshotsData, ListSnapshotsResponses, + ListSnapshotFilesData, + ListSnapshotFilesResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, CreateBackupScheduleData, @@ -348,6 +350,18 @@ export const listSnapshots = ( }); }; +/** + * List files and directories in a snapshot + */ +export const listSnapshotFiles = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files", + ...options, + }); +}; + /** * List all backup schedules */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 66073ef..0f98277 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -788,7 +788,7 @@ export type ListSnapshotsData = { name: string; }; query?: { - volumeId?: number; + volumeId?: string; }; url: "/api/v1/repositories/{name}/snapshots"; }; @@ -810,6 +810,47 @@ export type ListSnapshotsResponses = { export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; +export type ListSnapshotFilesData = { + body?: never; + path: { + name: string; + snapshotId: string; + }; + query?: { + path?: string; + }; + url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files"; +}; + +export type ListSnapshotFilesResponses = { + /** + * List of files and directories in the snapshot + */ + 200: { + files: Array<{ + name: string; + path: string; + type: string; + atime?: string; + ctime?: string; + gid?: number; + mode?: number; + mtime?: string; + size?: number; + uid?: number; + }>; + snapshot: { + hostname: string; + id: string; + paths: Array; + short_id: string; + time: string; + }; + }; +}; + +export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses]; + export type ListBackupSchedulesData = { body?: never; path?: never; diff --git a/apps/client/app/modules/details/components/schedule-summary.tsx b/apps/client/app/modules/details/components/schedule-summary.tsx index c960beb..e652a3c 100644 --- a/apps/client/app/modules/details/components/schedule-summary.tsx +++ b/apps/client/app/modules/details/components/schedule-summary.tsx @@ -23,7 +23,7 @@ export const ScheduleSummary = (props: Props) => { const { data: snapshots, isLoading: loadingSnapshots } = useQuery({ ...listSnapshotsOptions({ path: { name: repository.name }, - query: { volumeId: volume.id }, + query: { volumeId: volume.id.toString() }, }), refetchInterval: 10000, refetchOnWindowFocus: true, diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index 1ea4674..c030649 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -8,10 +8,13 @@ import { listRepositoriesDto, listSnapshotsDto, listSnapshotsFilters, + listSnapshotFilesDto, + listSnapshotFilesQuery, type DeleteRepositoryDto, type GetRepositoryDto, type ListRepositoriesDto, type ListSnapshotsDto, + type ListSnapshotFilesDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; @@ -68,4 +71,14 @@ export const repositoriesController = new Hono() c.header("Cache-Control", "max-age=30, stale-while-revalidate=300"); return c.json(response, 200); + }) + .get("/:name/snapshots/:snapshotId/files", listSnapshotFilesDto, validator("query", listSnapshotFilesQuery), async (c) => { + const { name, snapshotId } = c.req.param(); + const { path } = c.req.valid("query"); + + const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path); + + c.header("Cache-Control", "max-age=300, stale-while-revalidate=600"); + + return c.json(result, 200); }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 4f786b7..77764de 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -164,3 +164,52 @@ export const listSnapshotsDto = describeRoute({ }, }, }); + +/** + * List files in a snapshot + */ +export const snapshotFileNodeSchema = type({ + name: "string", + type: "string", + path: "string", + uid: "number?", + gid: "number?", + size: "number?", + mode: "number?", + mtime: "string?", + atime: "string?", + ctime: "string?", +}); + +export const listSnapshotFilesResponse = type({ + snapshot: type({ + id: "string", + short_id: "string", + time: "string", + hostname: "string", + paths: "string[]", + }), + files: snapshotFileNodeSchema.array(), +}); + +export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer; + +export const listSnapshotFilesQuery = type({ + path: "string?", +}); + +export const listSnapshotFilesDto = describeRoute({ + description: "List files and directories in a snapshot", + tags: ["Repositories"], + operationId: "listSnapshotFiles", + responses: { + 200: { + description: "List of files and directories in the snapshot", + content: { + "application/json": { + schema: resolver(listSnapshotFilesResponse), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index d83ce61..f950087 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -134,10 +134,38 @@ const listSnapshots = async (name: string, volumeId?: number) => { return snapshots; }; +const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + const result = await restic.ls(repository.config, snapshotId, path); + + if (!result.snapshot) { + throw new NotFoundError("Snapshot not found or empty"); + } + + return { + snapshot: { + id: result.snapshot.id, + short_id: result.snapshot.short_id, + time: result.snapshot.time, + hostname: result.snapshot.hostname, + paths: result.snapshot.paths, + }, + files: result.nodes, + }; +}; + export const repositoriesService = { listRepositories, createRepository, getRepository, deleteRepository, listSnapshots, + listSnapshotFiles, }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 70bc913..1c28362 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -236,6 +236,86 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => { return { success: true }; }; +const lsNodeSchema = type({ + name: "string", + type: "string", + path: "string", + uid: "number?", + gid: "number?", + size: "number?", + mode: "number?", + mtime: "string?", + atime: "string?", + ctime: "string?", + struct_type: "'node'", +}); + +const lsSnapshotInfoSchema = type({ + time: "string", + parent: "string?", + tree: "string", + paths: "string[]", + hostname: "string", + username: "string?", + id: "string", + short_id: "string", + struct_type: "'snapshot'", + message_type: "'snapshot'", +}); + +const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config); + + const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"]; + + if (path) { + args.push(path); + } + + const res = await $`restic ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic ls failed: ${res.stderr}`); + throw new Error(`Restic ls failed: ${res.stderr}`); + } + + // The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes + const stdout = res.text(); + const lines = stdout + .trim() + .split("\n") + .filter((line) => line.trim()); + + if (lines.length === 0) { + return { snapshot: null, nodes: [] }; + } + + // First line is snapshot info + const snapshotLine = JSON.parse(lines[0] ?? "{}"); + const snapshot = lsSnapshotInfoSchema(snapshotLine); + + if (snapshot instanceof type.errors) { + logger.error(`Restic ls snapshot info validation failed: ${snapshot}`); + throw new Error(`Restic ls snapshot info validation failed: ${snapshot}`); + } + + const nodes: Array = []; + for (let i = 1; i < lines.length; i++) { + const nodeLine = JSON.parse(lines[i] ?? "{}"); + const nodeValidation = lsNodeSchema(nodeLine); + + if (nodeValidation instanceof type.errors) { + logger.warn(`Skipping invalid node: ${nodeValidation}`); + continue; + } + + nodes.push(nodeValidation); + } + + return { snapshot, nodes }; +}; + export const restic = { ensurePassfile, init, @@ -243,4 +323,5 @@ export const restic = { restore, snapshots, forget, + ls, };