import { BadRequestException, Body, Controller, Get, Logger, Patch, Post, Res, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { BackupSettings } from '../../generated/prisma'; import { BackupService } from './backup.service'; import { ReauthService } from '../auth/reauth/reauth.service'; import { BackupImportProofDto } from './dto/backup-import-proof.dto'; import { BackupScheduleStatus, BackupSchedulerService, } from './backup-scheduler.service'; import { BackupSettingsService } from './backup-settings.service'; import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto'; import { backupMaxImportBytes, exceedsBackupImportLimit, formatMegabytes, } from './backup-import-limit'; @ApiTags('backup') @ApiBearerAuth() @Controller('backup') export class BackupController { private readonly logger = new Logger(BackupController.name); constructor( private readonly backupService: BackupService, private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSettingsService: BackupSettingsService, private readonly reauth: ReauthService, ) {} @RequirePermission('backup:export') @Get('schedule') getSchedule(): Promise { return this.backupSchedulerService.getStatus(); } @RequirePermission('backup:export') @Post('schedule/run-now') runScheduleNow(): Promise<{ fileName: string }> { return this.backupSchedulerService.runNow(); } @RequirePermission('backup:configure') @Get('settings') getSettings(): Promise { return this.backupSettingsService.get(); } @RequirePermission('backup:configure') @Patch('settings') async updateSettings( @Body() dto: UpdateBackupSettingsDto, @CurrentUser() actor: AuthenticatedUser, ): Promise { const settings = await this.backupSettingsService.update(dto, actor.id); await this.backupSchedulerService.reload(); return settings; } @RequirePermission('backup:export') @Get('export') async export(@Res() res: Response): Promise { const sealed = await this.backupService.exportSealed(); const body = JSON.stringify(sealed); const sizeBytes = Buffer.byteLength(body); if (exceedsBackupImportLimit(sizeBytes)) { // Still delivered: losing the data is worse than needing a raised // BACKUP_MAX_IMPORT_BYTES to restore it. The UI compares the file // against the limit from GET /backup/schedule and warns there. this.logger.warn( `Exportiertes Backup ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern`, ); } const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Cache-Control', 'no-store'); res.send(body); } @RequirePermission('backup:import') @Post('import') @ApiConsumes('multipart/form-data') @UseInterceptors( // Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES). FileInterceptor('file', { fileFilter: (_req, file, callback) => { if (!file.originalname.toLowerCase().endsWith('.json')) { callback( new BadRequestException('Nur JSON-Dateien werden unterstützt'), false, ); return; } callback(null, true); }, }), ) async import( @UploadedFile() file: Express.Multer.File | undefined, @Body() proof: BackupImportProofDto, @CurrentUser() actor: AuthenticatedUser, ) { // Restoring overwrites every account: prove it is really you, now. await this.reauth.verify(actor.id, proof); if (!file) { throw new BadRequestException('No file uploaded'); } let parsed: unknown; try { parsed = JSON.parse(file.buffer.toString('utf-8')); } catch { throw new BadRequestException('Datei ist kein gültiges JSON'); } const summary = await this.backupService.importFile(parsed, actor.id); // The backup may have restored the schedule's settings too. await this.backupSchedulerService.reload(); return summary; } }