zerobyte/app/server/modules/repositories/repositories.controller.ts
Nico d479bfaddc
feat: snapshot restores through rpc (#930)
* feat: snapshot restores through rpc

* fix(restore): do not wait for lock before returning response

* chore: fix liniting issue
2026-05-31 21:48:32 +02:00

326 lines
11 KiB
TypeScript

import { Readable } from "node:stream";
import { Hono } from "hono";
import { validator } from "hono-openapi";
import { streamSSE } from "hono/streaming";
import { create } from "content-disposition";
import {
createRepositoryBody,
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
deleteSnapshotsBody,
deleteSnapshotsDto,
startDoctorDto,
cancelDoctorDto,
getRepositoryDto,
getRepositoryStatsDto,
refreshRepositoryStatsDto,
getSnapshotDetailsDto,
refreshSnapshotsDto,
listRcloneRemotesDto,
listRepositoriesDto,
listSnapshotFilesDto,
listSnapshotFilesQuery,
listSnapshotsDto,
listSnapshotsFilters,
dumpSnapshotDto,
dumpSnapshotQuery,
restoreSnapshotBody,
restoreSnapshotDto,
tagSnapshotsBody,
tagSnapshotsDto,
updateRepositoryBody,
updateRepositoryDto,
devPanelExecBody,
devPanelExecDto,
unlockRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DeleteSnapshotsResponseDto,
type StartDoctorDto,
type CancelDoctorDto,
type GetRepositoryDto,
type GetRepositoryStatsDto,
type RefreshRepositoryStatsDto,
type GetSnapshotDetailsDto,
type RefreshSnapshotsDto,
type ListRepositoriesDto,
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
type TagSnapshotsResponseDto,
type UpdateRepositoryDto,
type UnlockRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { toMessage } from "~/server/utils/errors";
import { requireDevPanel } from "../auth/dev-panel.middleware";
import { getSnapshotDuration } from "../../utils/snapshots";
import { asShortId } from "~/server/utils/branded";
export const repositoriesController = new Hono()
.use(requireAuth)
.get("/", listRepositoriesDto, async (c) => {
const repositories = await repositoriesService.listRepositories();
return c.json<ListRepositoriesDto>(repositories, 200);
})
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
const body = c.req.valid("json");
const res = await repositoriesService.createRepository(body.name, body.config, body.compressionMode);
return c.json({ message: "Repository created", repository: res.repository }, 201);
})
.get("/rclone-remotes", listRcloneRemotesDto, async (c) => {
const remoteNames = await listRcloneRemotes();
const remotes = await Promise.all(
remoteNames.map(async (name) => {
const info = await getRcloneRemoteInfo(name);
return {
name,
type: info?.type ?? "unknown",
};
}),
);
return c.json(remotes);
})
.get("/:shortId", getRepositoryDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const res = await repositoriesService.getRepository(shortId);
return c.json<GetRepositoryDto>(res.repository, 200);
})
.get("/:shortId/stats", getRepositoryStatsDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const stats = await repositoriesService.getRepositoryStats(shortId);
return c.json<GetRepositoryStatsDto>(stats, 200);
})
.post("/:shortId/stats/refresh", refreshRepositoryStatsDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const stats = await repositoriesService.refreshRepositoryStats(shortId);
return c.json<RefreshRepositoryStatsDto>(stats, 200);
})
.delete("/:shortId", deleteRepositoryDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
await repositoriesService.deleteRepository(shortId);
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
})
.get("/:shortId/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const { backupId } = c.req.valid("query");
const backupShortId = backupId ? asShortId(backupId) : undefined;
const [res, retentionCategories] = await Promise.all([
repositoriesService.listSnapshots(shortId, backupShortId),
repositoriesService.getRetentionCategories(shortId, backupShortId),
]);
const snapshots = res.map((snapshot) => {
const { summary } = snapshot;
const duration = getSnapshotDuration(summary);
return {
short_id: snapshot.short_id,
duration,
paths: snapshot.paths,
tags: snapshot.tags ?? [],
size: summary?.total_bytes_processed ?? 0,
time: new Date(snapshot.time).getTime(),
retentionCategories: retentionCategories.get(snapshot.short_id) ?? [],
summary: summary,
};
});
return c.json<ListSnapshotsDto>(snapshots, 200);
})
.post("/:shortId/snapshots/refresh", refreshSnapshotsDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const result = await repositoriesService.refreshSnapshots(shortId);
return c.json<RefreshSnapshotsDto>(result, 200);
})
.get("/:shortId/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const snapshotId = c.req.param("snapshotId");
const snapshot = await repositoriesService.getSnapshotDetails(shortId, snapshotId);
const duration = getSnapshotDuration(snapshot.summary);
const response = {
short_id: snapshot.short_id,
duration,
time: new Date(snapshot.time).getTime(),
paths: snapshot.paths,
hostname: snapshot.hostname,
size: snapshot.summary?.total_bytes_processed ?? 0,
tags: snapshot.tags ?? [],
retentionCategories: [],
summary: snapshot.summary,
};
return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
"/:shortId/snapshots/:snapshotId/files",
listSnapshotFilesDto,
validator("query", listSnapshotFilesQuery),
async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const snapshotId = c.req.param("snapshotId");
const { path, ...query } = c.req.valid("query");
const decodedPath = path ? decodeURIComponent(path) : undefined;
const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, {
offset,
limit,
});
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200);
},
)
.get("/:shortId/snapshots/:snapshotId/dump", dumpSnapshotDto, validator("query", dumpSnapshotQuery), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const snapshotId = c.req.param("snapshotId");
const { path, kind } = c.req.valid("query");
const dumpStream = await repositoriesService.dumpSnapshot(shortId, snapshotId, path, kind);
const sourceStream = Readable.toWeb(dumpStream.stream) as unknown as ReadableStream<Uint8Array>;
const reader = sourceStream.getReader();
const webStream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
} catch (error) {
controller.error(error);
}
},
async cancel(reason) {
dumpStream.abort();
await reader.cancel(reason).catch(() => {});
},
});
const filename = dumpStream.filename || "snapshot.tar";
return new Response(webStream, {
status: 200,
headers: {
"Content-Type": dumpStream.contentType,
"Content-Disposition": create(filename, {
fallback: filename.replace(/[^\x20-\x7E]/g, "?"),
}),
"X-Content-Type-Options": "nosniff",
},
});
})
.post("/:shortId/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const { snapshotId, ...options } = c.req.valid("json");
const result = await repositoriesService.restoreSnapshot(shortId, snapshotId, options);
return c.json<RestoreSnapshotDto>(result, 202);
})
.post("/:shortId/doctor", startDoctorDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const result = await repositoriesService.startDoctor(shortId);
return c.json<StartDoctorDto>(result, 202);
})
.delete("/:shortId/doctor", cancelDoctorDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const result = await repositoriesService.cancelDoctor(shortId);
return c.json<CancelDoctorDto>(result, 200);
})
.post("/:shortId/unlock", unlockRepositoryDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const result = await repositoriesService.unlockRepository(shortId);
return c.json<UnlockRepositoryDto>(result, 200);
})
.delete("/:shortId/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const snapshotId = c.req.param("snapshotId");
await repositoriesService.deleteSnapshot(shortId, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
})
.delete("/:shortId/snapshots", deleteSnapshotsDto, validator("json", deleteSnapshotsBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const { snapshotIds } = c.req.valid("json");
await repositoriesService.deleteSnapshots(shortId, snapshotIds);
return c.json<DeleteSnapshotsResponseDto>({ message: "Snapshots deleted" }, 200);
})
.post("/:shortId/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const { snapshotIds, ...tags } = c.req.valid("json");
await repositoriesService.tagSnapshots(shortId, snapshotIds, tags);
return c.json<TagSnapshotsResponseDto>({ message: "Snapshots tagged" }, 200);
})
.patch("/:shortId", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const body = c.req.valid("json");
const res = await repositoriesService.updateRepository(shortId, body);
return c.json<UpdateRepositoryDto>(res.repository, 200);
})
.post(
"/:shortId/exec",
requireDevPanel,
requireOrgAdmin,
devPanelExecDto,
validator("json", devPanelExecBody),
async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const body = c.req.valid("json");
return streamSSE(c, async (stream) => {
const abortController = new AbortController();
stream.onAbort(() => abortController.abort());
const sendSSE = async (event: string, data: unknown) => {
await stream.writeSSE({ data: JSON.stringify(data), event });
};
try {
const result = await repositoriesService.execResticCommand(
shortId,
body.command,
body.args,
async (line) => sendSSE("output", { type: "stdout", line }),
async (line) => sendSSE("output", { type: "stderr", line }),
abortController.signal,
);
await sendSSE("done", { type: "done", exitCode: result.exitCode });
} catch (error) {
await sendSSE("error", { type: "error", message: toMessage(error) });
}
});
},
);