* feat: snapshot restores through rpc * fix(restore): do not wait for lock before returning response * chore: fix liniting issue
326 lines
11 KiB
TypeScript
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) });
|
|
}
|
|
});
|
|
},
|
|
);
|