Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
- Add audit_logs table with categories, severities, and indexes - Create AuditLogService and AuditLogRepository - Add REST API endpoints for viewing and exporting logs - Wire audit logging into auth, vehicles, admin, and backup features - Add desktop AdminLogsPage with filters and CSV export - Add mobile AdminLogsMobileScreen with card layout - Implement 90-day retention cleanup job - Remove old AuditLogPanel from AdminCatalogPage Security fixes: - Escape LIKE special characters to prevent pattern injection - Limit CSV export to 5000 records to prevent memory exhaustion - Add truncation warning headers for large exports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
/**
|
|
* @ai-summary Controller for backup API endpoints
|
|
* @ai-context Handles HTTP requests for backup operations
|
|
*/
|
|
|
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
import { Pool } from 'pg';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import { BackupService } from '../domain/backup.service';
|
|
import { BackupRestoreService } from '../domain/backup-restore.service';
|
|
import {
|
|
ListBackupsQuery,
|
|
CreateBackupBody,
|
|
BackupIdParam,
|
|
RestoreBody,
|
|
CreateScheduleBody,
|
|
UpdateScheduleBody,
|
|
ScheduleIdParam,
|
|
UpdateSettingsBody,
|
|
} from './backup.validation';
|
|
import { auditLogService } from '../../audit-log';
|
|
|
|
export class BackupController {
|
|
private backupService: BackupService;
|
|
private restoreService: BackupRestoreService;
|
|
|
|
constructor(pool: Pool) {
|
|
this.backupService = new BackupService(pool);
|
|
this.restoreService = new BackupRestoreService(pool);
|
|
}
|
|
|
|
// ============================================
|
|
// Backup Operations
|
|
// ============================================
|
|
|
|
async listBackups(
|
|
request: FastifyRequest<{ Querystring: ListBackupsQuery }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const result = await this.backupService.listBackups(request.query);
|
|
reply.send(result);
|
|
}
|
|
|
|
async createBackup(
|
|
request: FastifyRequest<{ Body: CreateBackupBody }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const adminSub = (request as any).userContext?.auth0Sub;
|
|
|
|
const result = await this.backupService.createBackup({
|
|
name: request.body.name,
|
|
backupType: 'manual',
|
|
createdBy: adminSub,
|
|
includeDocuments: request.body.includeDocuments,
|
|
});
|
|
|
|
if (result.success) {
|
|
// Log backup creation to unified audit log
|
|
await auditLogService.info(
|
|
'system',
|
|
adminSub || null,
|
|
`Backup created: ${request.body.name || 'Manual backup'}`,
|
|
'backup',
|
|
result.backupId,
|
|
{ name: request.body.name, includeDocuments: request.body.includeDocuments }
|
|
).catch(err => logger.error('Failed to log backup create audit event', { error: err }));
|
|
|
|
reply.status(201).send({
|
|
backupId: result.backupId,
|
|
status: 'completed',
|
|
message: 'Backup created successfully',
|
|
});
|
|
} else {
|
|
// Log backup failure
|
|
await auditLogService.error(
|
|
'system',
|
|
adminSub || null,
|
|
`Backup failed: ${request.body.name || 'Manual backup'}`,
|
|
'backup',
|
|
result.backupId,
|
|
{ error: result.error }
|
|
).catch(err => logger.error('Failed to log backup failure audit event', { error: err }));
|
|
|
|
reply.status(500).send({
|
|
backupId: result.backupId,
|
|
status: 'failed',
|
|
message: result.error,
|
|
});
|
|
}
|
|
}
|
|
|
|
async getBackup(
|
|
request: FastifyRequest<{ Params: BackupIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const backup = await this.backupService.getBackup(request.params.id);
|
|
|
|
if (!backup) {
|
|
reply.status(404).send({ error: 'Backup not found' });
|
|
return;
|
|
}
|
|
|
|
reply.send(backup);
|
|
}
|
|
|
|
async downloadBackup(
|
|
request: FastifyRequest<{ Params: BackupIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const download = await this.backupService.createDownloadStream(request.params.id);
|
|
|
|
if (!download) {
|
|
reply.status(404).send({ error: 'Backup not found or file not available' });
|
|
return;
|
|
}
|
|
|
|
return reply
|
|
.header('Content-Type', 'application/gzip')
|
|
.header('Content-Disposition', `attachment; filename="${download.filename}"`)
|
|
.header('Content-Length', download.size)
|
|
.send(download.stream);
|
|
}
|
|
|
|
async deleteBackup(
|
|
request: FastifyRequest<{ Params: BackupIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const deleted = await this.backupService.deleteBackup(request.params.id);
|
|
|
|
if (!deleted) {
|
|
reply.status(404).send({ error: 'Backup not found' });
|
|
return;
|
|
}
|
|
|
|
reply.status(204).send();
|
|
}
|
|
|
|
async uploadBackup(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const adminSub = (request as any).userContext?.auth0Sub;
|
|
|
|
// Handle multipart file upload
|
|
const data = await request.file();
|
|
if (!data) {
|
|
reply.status(400).send({ error: 'No file uploaded' });
|
|
return;
|
|
}
|
|
|
|
// Validate file type
|
|
const filename = data.filename;
|
|
if (!filename.endsWith('.tar.gz') && !filename.endsWith('.tgz')) {
|
|
reply.status(400).send({ error: 'Invalid file type. Expected .tar.gz archive' });
|
|
return;
|
|
}
|
|
|
|
// Save file temporarily
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
const os = await import('os');
|
|
|
|
const tempPath = path.join(os.tmpdir(), `upload-${Date.now()}-${filename}`);
|
|
const writeStream = fs.createWriteStream(tempPath);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
data.file.pipe(writeStream);
|
|
data.file.on('end', resolve);
|
|
data.file.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const backup = await this.backupService.importUploadedBackup(
|
|
tempPath,
|
|
filename,
|
|
adminSub
|
|
);
|
|
|
|
reply.status(201).send({
|
|
backupId: backup.id,
|
|
filename: backup.filename,
|
|
fileSizeBytes: backup.fileSizeBytes,
|
|
message: 'Backup uploaded successfully',
|
|
});
|
|
} catch (error) {
|
|
// Cleanup temp file on error
|
|
await fs.promises.unlink(tempPath).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Restore Operations
|
|
// ============================================
|
|
|
|
async previewRestore(
|
|
request: FastifyRequest<{ Params: BackupIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
try {
|
|
const preview = await this.restoreService.previewRestore(request.params.id);
|
|
reply.send(preview);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to preview restore';
|
|
logger.error('Preview restore failed', {
|
|
backupId: request.params.id,
|
|
error: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
reply.status(400).send({
|
|
error: errorMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
async executeRestore(
|
|
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const adminSub = (request as any).userContext?.auth0Sub;
|
|
|
|
try {
|
|
const result = await this.restoreService.executeRestore({
|
|
backupId: request.params.id,
|
|
createSafetyBackup: request.body?.createSafetyBackup ?? true,
|
|
});
|
|
|
|
if (result.success) {
|
|
// Log successful restore to unified audit log
|
|
await auditLogService.info(
|
|
'system',
|
|
adminSub || null,
|
|
`Backup restored: ${request.params.id}`,
|
|
'backup',
|
|
request.params.id,
|
|
{ safetyBackupId: result.safetyBackupId }
|
|
).catch(err => logger.error('Failed to log restore success audit event', { error: err }));
|
|
|
|
reply.send({
|
|
success: true,
|
|
safetyBackupId: result.safetyBackupId,
|
|
restoredAt: new Date().toISOString(),
|
|
message: 'Restore completed successfully',
|
|
});
|
|
} else {
|
|
// Log restore failure
|
|
await auditLogService.error(
|
|
'system',
|
|
adminSub || null,
|
|
`Backup restore failed: ${request.params.id}`,
|
|
'backup',
|
|
request.params.id,
|
|
{ error: result.error, safetyBackupId: result.safetyBackupId }
|
|
).catch(err => logger.error('Failed to log restore failure audit event', { error: err }));
|
|
|
|
reply.status(500).send({
|
|
success: false,
|
|
safetyBackupId: result.safetyBackupId,
|
|
error: result.error,
|
|
message: 'Restore failed',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to execute restore';
|
|
logger.error('Restore execution failed', {
|
|
backupId: request.params.id,
|
|
error: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
reply.status(400).send({
|
|
success: false,
|
|
error: errorMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
async getRestoreStatus(
|
|
_request: FastifyRequest,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const status = this.restoreService.getRestoreStatus();
|
|
reply.send(status);
|
|
}
|
|
|
|
// ============================================
|
|
// Schedule Operations
|
|
// ============================================
|
|
|
|
async listSchedules(
|
|
_request: FastifyRequest,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const schedules = await this.backupService.listSchedules();
|
|
reply.send(schedules);
|
|
}
|
|
|
|
async createSchedule(
|
|
request: FastifyRequest<{ Body: CreateScheduleBody }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const schedule = await this.backupService.createSchedule(
|
|
request.body.name,
|
|
request.body.frequency,
|
|
request.body.retentionCount,
|
|
request.body.isEnabled
|
|
);
|
|
|
|
reply.status(201).send(schedule);
|
|
}
|
|
|
|
async updateSchedule(
|
|
request: FastifyRequest<{ Params: ScheduleIdParam; Body: UpdateScheduleBody }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const schedule = await this.backupService.updateSchedule(
|
|
request.params.id,
|
|
request.body
|
|
);
|
|
|
|
if (!schedule) {
|
|
reply.status(404).send({ error: 'Schedule not found' });
|
|
return;
|
|
}
|
|
|
|
reply.send(schedule);
|
|
}
|
|
|
|
async deleteSchedule(
|
|
request: FastifyRequest<{ Params: ScheduleIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const deleted = await this.backupService.deleteSchedule(request.params.id);
|
|
|
|
if (!deleted) {
|
|
reply.status(404).send({ error: 'Schedule not found' });
|
|
return;
|
|
}
|
|
|
|
reply.status(204).send();
|
|
}
|
|
|
|
async toggleSchedule(
|
|
request: FastifyRequest<{ Params: ScheduleIdParam }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const schedule = await this.backupService.toggleSchedule(request.params.id);
|
|
|
|
if (!schedule) {
|
|
reply.status(404).send({ error: 'Schedule not found' });
|
|
return;
|
|
}
|
|
|
|
reply.send(schedule);
|
|
}
|
|
|
|
// ============================================
|
|
// Settings Operations
|
|
// ============================================
|
|
|
|
async getSettings(
|
|
_request: FastifyRequest,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const settings = await this.backupService.getSettings();
|
|
reply.send(settings);
|
|
}
|
|
|
|
async updateSettings(
|
|
request: FastifyRequest<{ Body: UpdateSettingsBody }>,
|
|
reply: FastifyReply
|
|
): Promise<void> {
|
|
const settings = await this.backupService.updateSettings(request.body);
|
|
reply.send(settings);
|
|
}
|
|
}
|