Files
motovaultpro/backend/src/features/backup/api/backup.controller.ts
Eric Gullickson c98211f4a2
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
feat: Implement centralized audit logging admin interface (refs #10)
- 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>
2026-01-11 11:09:09 -06:00

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);
}
}