mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
192 lines
7.7 KiB
TypeScript
192 lines
7.7 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
Param,
|
|
Post,
|
|
Put,
|
|
Req,
|
|
Res,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import type { Request, Response } from 'express';
|
|
import fs from 'fs';
|
|
import { readEnv } from '../../app-config';
|
|
import type { User } from '../../types';
|
|
import { BackupService } from './backup.service';
|
|
import { AutoBackupJob } from './auto-backup.job';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { AdminGuard } from '../auth/admin.guard';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
import { AutoBackupSettingsDto } from './backup.dto';
|
|
import { getClientIp } from '../audit/client-ip';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { StorageNotFoundError } from '../storage/storage.types';
|
|
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
|
|
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
|
|
|
/**
|
|
* /api/backup — admin-only database backup management (list, create, download,
|
|
* restore from a stored or uploaded zip, auto-backup settings, delete).
|
|
*
|
|
* Byte-identical to the legacy Express route (server/src/routes/backup.ts):
|
|
* admin-gated, the create rate-limit (429), the filename validation (400/404),
|
|
* the audit-log writes, res.download for downloads and the tmp-file cleanup for
|
|
* uploads. All JSON responses answer 200.
|
|
*/
|
|
@Controller('api/backup')
|
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
|
export class BackupController {
|
|
constructor(
|
|
private readonly backup: BackupService,
|
|
private readonly audit: AuditService,
|
|
private readonly autoBackup: AutoBackupJob,
|
|
private readonly env: RuntimeEnvService,
|
|
) {}
|
|
|
|
@Get('list')
|
|
async list() {
|
|
try {
|
|
return { backups: await this.backup.listBackups() };
|
|
} catch {
|
|
throw new HttpException({ error: 'Error loading backups' }, 500);
|
|
}
|
|
}
|
|
|
|
@Post('create')
|
|
@HttpCode(200) // Express answers create with res.json (200), not the POST-default 201.
|
|
async create(@CurrentUser() user: User, @Req() req: Request) {
|
|
if (!this.backup.checkRateLimit(req.ip || 'unknown', 3, this.backup.rateWindow)) {
|
|
throw new HttpException({ error: 'Too many backup requests. Please try again later.' }, 429);
|
|
}
|
|
try {
|
|
const backup = await this.backup.createBackup();
|
|
await this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
|
|
return { success: true, backup };
|
|
} catch {
|
|
throw new HttpException({ error: 'Error creating backup' }, 500);
|
|
}
|
|
}
|
|
|
|
@Get('download/:filename')
|
|
async download(@Param('filename') filename: string, @Res() res: Response): Promise<void> {
|
|
if (!this.backup.isValidBackupFilename(filename)) {
|
|
throw new HttpException({ error: 'Invalid filename' }, 400);
|
|
}
|
|
if (!(await this.backup.backupFileExists(filename))) {
|
|
throw new HttpException({ error: 'Backup not found' }, 404);
|
|
}
|
|
try {
|
|
await this.backup.sendBackupToResponse(filename, res);
|
|
} catch (err) {
|
|
// The pre-check above owns the normal miss; this only covers a delete
|
|
// racing between the check and the send.
|
|
if (err instanceof StorageNotFoundError) {
|
|
throw new HttpException({ error: 'Backup not found' }, 404);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
@ManagedForbidden('a restore replaces database and uploads, and the operator owns the recovery point')
|
|
@Post('restore/:filename')
|
|
@HttpCode(200) // Express answers restore with res.json (200).
|
|
async restore(@CurrentUser() user: User, @Param('filename') filename: string, @Req() req: Request) {
|
|
if (!this.backup.isValidBackupFilename(filename)) {
|
|
throw new HttpException({ error: 'Invalid filename' }, 400);
|
|
}
|
|
if (!(await this.backup.backupFileExists(filename))) {
|
|
throw new HttpException({ error: 'Backup not found' }, 404);
|
|
}
|
|
try {
|
|
const result = await this.backup.restoreBackup(filename);
|
|
if (!result.success) {
|
|
throw new HttpException({ error: result.error }, result.status || 400);
|
|
}
|
|
await this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
|
|
return { success: true };
|
|
} catch (err) {
|
|
if (err instanceof HttpException) throw err;
|
|
throw new HttpException({ error: 'Error restoring backup' }, 500);
|
|
}
|
|
}
|
|
|
|
@ManagedForbidden(
|
|
'restoring from an uploaded archive replaces the database and the encryption key',
|
|
{ enforcedInHandler: true },
|
|
)
|
|
@Post('upload-restore')
|
|
@HttpCode(200) // Express answers upload-restore with res.json (200).
|
|
@UseInterceptors(FileInterceptor('backup'))
|
|
async uploadRestore(@CurrentUser() user: User, @UploadedFile() file: Express.Multer.File | undefined, @Req() req: Request) {
|
|
// Checked here rather than in the guard: a guard runs before the multipart
|
|
// parser, so throwing there leaves the body unread and the client sees an
|
|
// ECONNRESET instead of this 403 (PROFILE-015). The marker above still puts
|
|
// the route in the boot-gate inventory.
|
|
if (isManagedBlocked(this.env)) {
|
|
throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403);
|
|
}
|
|
if (!file) {
|
|
throw new HttpException({ error: 'No file uploaded' }, 400);
|
|
}
|
|
const zipPath = file.path;
|
|
const origName = file.originalname || 'upload.zip';
|
|
try {
|
|
const result = await this.backup.restoreFromZip(zipPath);
|
|
if (!result.success) {
|
|
throw new HttpException({ error: result.error }, result.status || 400);
|
|
}
|
|
await this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
|
|
return { success: true };
|
|
} catch (err) {
|
|
if (err instanceof HttpException) throw err;
|
|
throw new HttpException({ error: 'Error restoring backup' }, 500);
|
|
} finally {
|
|
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
|
}
|
|
}
|
|
|
|
@Get('auto-settings')
|
|
autoSettings() {
|
|
try {
|
|
return this.autoBackup.getAutoSettings();
|
|
} catch (err) {
|
|
console.error('[backup] GET auto-settings:', err);
|
|
throw new HttpException({ error: 'Could not load backup settings' }, 500);
|
|
}
|
|
}
|
|
|
|
@ManagedForbidden('the operator schedules backups off-volume; a second schedule inside it is not one')
|
|
@Put('auto-settings')
|
|
async updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) {
|
|
try {
|
|
const settings = this.autoBackup.updateAutoSettings(body || {});
|
|
await this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
|
|
return { settings };
|
|
} catch (err) {
|
|
console.error('[backup] PUT auto-settings:', err);
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new HttpException({ error: 'Could not save auto-backup settings', detail: !readEnv().app.isProduction ? msg : undefined }, 500);
|
|
}
|
|
}
|
|
|
|
@Delete(':filename')
|
|
async remove(@CurrentUser() user: User, @Param('filename') filename: string, @Req() req: Request) {
|
|
if (!this.backup.isValidBackupFilename(filename)) {
|
|
throw new HttpException({ error: 'Invalid filename' }, 400);
|
|
}
|
|
if (!(await this.backup.backupFileExists(filename))) {
|
|
throw new HttpException({ error: 'Backup not found' }, 404);
|
|
}
|
|
await this.backup.deleteBackup(filename);
|
|
await this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
|
|
return { success: true };
|
|
}
|
|
}
|