Files
proxpanel/backend/src/routes/backup.routes.ts

492 lines
15 KiB
TypeScript

import {
BackupRestoreMode,
BackupRestoreStatus,
BackupSchedule,
BackupSource,
BackupStatus,
BackupType,
SnapshotFrequency
} from "@prisma/client";
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error";
import { prisma } from "../lib/prisma";
import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth";
import { logAudit } from "../services/audit.service";
import {
createBackup,
createRestoreTask,
createSnapshotJob,
deleteBackup,
deleteSnapshotJob,
listBackupPolicies,
listBackups,
listRestoreTasks,
listSnapshotJobs,
runRestoreTaskNow,
runSnapshotJobNow,
toggleBackupProtection,
updateSnapshotJob,
upsertBackupPolicy
} from "../services/backup.service";
const router = Router();
const createBackupSchema = z.object({
vm_id: z.string().min(1),
type: z.nativeEnum(BackupType).optional(),
source: z.nativeEnum(BackupSource).optional(),
schedule: z.nativeEnum(BackupSchedule).optional(),
retention_days: z.number().int().positive().optional(),
storage: z.string().optional(),
route_key: z.string().optional(),
is_protected: z.boolean().optional(),
notes: z.string().optional(),
requested_size_mb: z.number().positive().optional()
});
const protectionSchema = z.object({
is_protected: z.boolean()
});
const createRestoreSchema = z.object({
backup_id: z.string().min(1),
target_vm_id: z.string().optional(),
mode: z.nativeEnum(BackupRestoreMode),
requested_files: z.array(z.string().min(1)).optional(),
pbs_enabled: z.boolean().optional(),
run_immediately: z.boolean().default(true)
});
const createSnapshotSchema = z.object({
vm_id: z.string().min(1),
name: z.string().min(2),
frequency: z.nativeEnum(SnapshotFrequency),
interval: z.number().int().positive().optional(),
day_of_week: z.number().int().min(0).max(6).optional(),
hour_utc: z.number().int().min(0).max(23).optional(),
minute_utc: z.number().int().min(0).max(59).optional(),
retention: z.number().int().positive().optional(),
enabled: z.boolean().optional()
});
const updateSnapshotSchema = z.object({
name: z.string().min(2).optional(),
frequency: z.nativeEnum(SnapshotFrequency).optional(),
interval: z.number().int().positive().optional(),
day_of_week: z.number().int().min(0).max(6).nullable().optional(),
hour_utc: z.number().int().min(0).max(23).optional(),
minute_utc: z.number().int().min(0).max(59).optional(),
retention: z.number().int().positive().optional(),
enabled: z.boolean().optional()
});
const upsertPolicySchema = z.object({
tenant_id: z.string().optional(),
billing_plan_id: z.string().optional(),
max_files: z.number().int().positive().optional(),
max_total_size_mb: z.number().positive().optional(),
max_protected_files: z.number().int().positive().optional(),
allow_file_restore: z.boolean().optional(),
allow_cross_vm_restore: z.boolean().optional(),
allow_pbs_restore: z.boolean().optional()
});
function parseOptionalBackupStatus(value: unknown) {
if (typeof value !== "string") return undefined;
const normalized = value.toUpperCase();
return Object.values(BackupStatus).includes(normalized as BackupStatus)
? (normalized as BackupStatus)
: undefined;
}
function parseOptionalRestoreStatus(value: unknown) {
if (typeof value !== "string") return undefined;
const normalized = value.toUpperCase();
return Object.values(BackupRestoreStatus).includes(normalized as BackupRestoreStatus)
? (normalized as BackupRestoreStatus)
: undefined;
}
async function ensureVmTenantScope(vmId: string, req: Express.Request) {
const vm = await prisma.virtualMachine.findUnique({
where: { id: vmId },
select: {
id: true,
tenant_id: true,
name: true
}
});
if (!vm) throw new HttpError(404, "VM not found", "VM_NOT_FOUND");
if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
return vm;
}
async function ensureBackupTenantScope(backupId: string, req: Express.Request) {
const backup = await prisma.backup.findUnique({
where: { id: backupId },
include: {
vm: {
select: {
id: true,
tenant_id: true,
name: true
}
}
}
});
if (!backup) throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND");
const tenantId = backup.tenant_id ?? backup.vm.tenant_id;
if (isTenantScopedUser(req) && req.user?.tenant_id && tenantId !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
return backup;
}
async function ensureRestoreTaskTenantScope(taskId: string, req: Express.Request) {
const task = await prisma.backupRestoreTask.findUnique({
where: { id: taskId },
include: {
source_vm: {
select: {
tenant_id: true
}
}
}
});
if (!task) throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND");
if (isTenantScopedUser(req) && req.user?.tenant_id && task.source_vm.tenant_id !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
return task;
}
async function ensureSnapshotJobTenantScope(jobId: string, req: Express.Request) {
const job = await prisma.snapshotJob.findUnique({
where: { id: jobId },
include: {
vm: {
select: {
tenant_id: true
}
}
}
});
if (!job) throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND");
if (isTenantScopedUser(req) && req.user?.tenant_id && job.vm.tenant_id !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
return job;
}
router.get("/", requireAuth, authorize("backup:read"), async (req, res, next) => {
try {
const status = parseOptionalBackupStatus(req.query.status);
const vmId = typeof req.query.vm_id === "string" ? req.query.vm_id : undefined;
const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined;
if (vmId) {
await ensureVmTenantScope(vmId, req);
}
const result = await listBackups({
tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined,
status,
vmId,
limit,
offset
});
res.json(result);
} catch (error) {
next(error);
}
});
router.post("/", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = createBackupSchema.parse(req.body ?? {});
await ensureVmTenantScope(payload.vm_id, req);
const backup = await createBackup({
vmId: payload.vm_id,
type: payload.type,
source: payload.source,
schedule: payload.schedule,
retentionDays: payload.retention_days,
storage: payload.storage,
routeKey: payload.route_key,
isProtected: payload.is_protected,
notes: payload.notes,
requestedSizeMb: payload.requested_size_mb,
createdBy: req.user?.email
});
await logAudit({
action: "backup.create",
resource_type: "BACKUP",
resource_id: backup.id,
resource_name: backup.vm_name,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: payload,
ip_address: req.ip
});
res.status(201).json(backup);
} catch (error) {
next(error);
}
});
router.patch("/:id/protection", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = protectionSchema.parse(req.body ?? {});
await ensureBackupTenantScope(req.params.id, req);
const backup = await toggleBackupProtection(req.params.id, payload.is_protected);
res.json(backup);
} catch (error) {
next(error);
}
});
router.delete("/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
await ensureBackupTenantScope(req.params.id, req);
const force = req.query.force === "true";
await deleteBackup(req.params.id, force);
res.status(204).send();
} catch (error) {
next(error);
}
});
router.get("/restores", requireAuth, authorize("backup:read"), async (req, res, next) => {
try {
const status = parseOptionalRestoreStatus(req.query.status);
const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined;
const result = await listRestoreTasks({
tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined,
status,
limit,
offset
});
res.json(result);
} catch (error) {
next(error);
}
});
router.post("/restores", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = createRestoreSchema.parse(req.body ?? {});
await ensureBackupTenantScope(payload.backup_id, req);
if (payload.target_vm_id) {
await ensureVmTenantScope(payload.target_vm_id, req);
}
const task = await createRestoreTask({
backupId: payload.backup_id,
targetVmId: payload.target_vm_id,
mode: payload.mode,
requestedFiles: payload.requested_files,
pbsEnabled: payload.pbs_enabled,
createdBy: req.user?.email,
runImmediately: payload.run_immediately
});
await logAudit({
action: "backup.restore.create",
resource_type: "BACKUP",
resource_id: payload.backup_id,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: payload,
ip_address: req.ip
});
res.status(201).json(task);
} catch (error) {
next(error);
}
});
router.post("/restores/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
await ensureRestoreTaskTenantScope(req.params.id, req);
const task = await runRestoreTaskNow(req.params.id);
res.json(task);
} catch (error) {
next(error);
}
});
router.get("/snapshot-jobs", requireAuth, authorize("backup:read"), async (req, res, next) => {
try {
const jobs = await listSnapshotJobs({
tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined
});
res.json({ data: jobs });
} catch (error) {
next(error);
}
});
router.post("/snapshot-jobs", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = createSnapshotSchema.parse(req.body ?? {});
await ensureVmTenantScope(payload.vm_id, req);
const job = await createSnapshotJob({
vmId: payload.vm_id,
name: payload.name,
frequency: payload.frequency,
interval: payload.interval,
dayOfWeek: payload.day_of_week,
hourUtc: payload.hour_utc,
minuteUtc: payload.minute_utc,
retention: payload.retention,
enabled: payload.enabled,
createdBy: req.user?.email
});
await logAudit({
action: "snapshot_job.create",
resource_type: "BACKUP",
resource_id: job.id,
resource_name: job.name,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: payload,
ip_address: req.ip
});
res.status(201).json(job);
} catch (error) {
next(error);
}
});
router.patch("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = updateSnapshotSchema.parse(req.body ?? {});
await ensureSnapshotJobTenantScope(req.params.id, req);
const job = await updateSnapshotJob(req.params.id, {
name: payload.name,
frequency: payload.frequency,
interval: payload.interval,
dayOfWeek: payload.day_of_week,
hourUtc: payload.hour_utc,
minuteUtc: payload.minute_utc,
retention: payload.retention,
enabled: payload.enabled
});
res.json(job);
} catch (error) {
next(error);
}
});
router.delete("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
await ensureSnapshotJobTenantScope(req.params.id, req);
await deleteSnapshotJob(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
});
router.post("/snapshot-jobs/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
await ensureSnapshotJobTenantScope(req.params.id, req);
const result = await runSnapshotJobNow(req.params.id);
res.json(result);
} catch (error) {
next(error);
}
});
router.get("/policies", requireAuth, authorize("backup:read"), async (_req, res, next) => {
try {
const all = await listBackupPolicies();
const data =
isTenantScopedUser(_req) && _req.user?.tenant_id
? all.filter((item) => item.tenant_id === _req.user?.tenant_id)
: all;
res.json({ data });
} catch (error) {
next(error);
}
});
router.post("/policies", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = upsertPolicySchema.parse(req.body ?? {});
const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id;
if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
const policy = await upsertBackupPolicy({
tenantId,
billingPlanId: payload.billing_plan_id,
maxFiles: payload.max_files,
maxTotalSizeMb: payload.max_total_size_mb,
maxProtectedFiles: payload.max_protected_files,
allowFileRestore: payload.allow_file_restore,
allowCrossVmRestore: payload.allow_cross_vm_restore,
allowPbsRestore: payload.allow_pbs_restore
});
res.status(201).json(policy);
} catch (error) {
next(error);
}
});
router.patch("/policies/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => {
try {
const payload = upsertPolicySchema.parse(req.body ?? {});
const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id;
if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) {
throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION");
}
const policy = await upsertBackupPolicy({
policyId: req.params.id,
tenantId,
billingPlanId: payload.billing_plan_id,
maxFiles: payload.max_files,
maxTotalSizeMb: payload.max_total_size_mb,
maxProtectedFiles: payload.max_protected_files,
allowFileRestore: payload.allow_file_restore,
allowCrossVmRestore: payload.allow_cross_vm_restore,
allowPbsRestore: payload.allow_pbs_restore
});
res.json(policy);
} catch (error) {
next(error);
}
});
export default router;