chore: initial commit for phase07

This commit is contained in:
hibna
2026-02-22 00:25:39 +03:00
parent 5709d8bc10
commit 124e4f8921
16 changed files with 1973 additions and 23 deletions
+177
View File
@@ -0,0 +1,177 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, backups } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
const BackupParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
backupId: Type.String({ format: 'uuid' }),
}),
};
const CreateBackupBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
isLocked: Type.Optional(Type.Boolean({ default: false })),
});
export default async function backupRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /backups — list all backups for a server
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'backup.read');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const backupList = await app.db.query.backups.findMany({
where: eq(backups.serverId, serverId),
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
return { backups: backupList };
});
// POST /backups — create a backup
app.post('/', { schema: { ...ParamSchema, body: CreateBackupBody } }, async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'backup.create');
const body = request.body as { name: string; isLocked?: boolean };
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
// Create backup record (pending — daemon will update when complete)
const [backup] = await app.db
.insert(backups)
.values({
serverId,
name: body.name,
isLocked: body.isLocked ?? false,
})
.returning();
// TODO: Send gRPC CreateBackup to daemon
// Daemon will:
// 1. tar+gz the server directory
// 2. Upload to @source/cdn
// 3. Callback to API with cdnPath, sizeBytes, checksum
// 4. API updates backup record with completedAt
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.create',
metadata: { name: body.name },
});
return reply.code(201).send(backup);
});
// POST /backups/:backupId/restore — restore a backup
app.post('/:backupId/restore', { schema: BackupParamSchema }, async (request) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.restore');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
// TODO: Send gRPC RestoreBackup to daemon
// Daemon will:
// 1. Stop the server
// 2. Download backup from @source/cdn
// 3. Extract tar.gz over server directory
// 4. Start the server
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.restore',
metadata: { backupName: backup.name, backupId },
});
return { success: true, message: 'Restore initiated' };
});
// PATCH /backups/:backupId/lock — toggle backup lock
app.patch('/:backupId/lock', { schema: BackupParamSchema }, async (request) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.manage');
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
const [updated] = await app.db
.update(backups)
.set({ isLocked: !backup.isLocked })
.where(eq(backups.id, backupId))
.returning();
return updated;
});
// DELETE /backups/:backupId — delete a backup
app.delete('/:backupId', { schema: BackupParamSchema }, async (request, reply) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.delete');
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
// TODO: Send gRPC DeleteBackup to daemon to remove from CDN
await app.db.delete(backups).where(eq(backups.id, backupId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.delete',
metadata: { name: backup.name },
});
return reply.code(204).send();
});
}