refactor(backup): download through storage sendToResponse

This commit is contained in:
jubnl
2026-08-17 07:03:13 +02:00
parent 8039362e04
commit e4109db9f6
6 changed files with 95 additions and 15 deletions
+11 -1
View File
@@ -28,6 +28,7 @@ import { AutoBackupSettingsDto } from './backup.dto';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import { getUploadTmpDir, MAX_BACKUP_UPLOAD_SIZE } from './backup.impl';
import { StorageNotFoundError } from '../storage/storage.types';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
@@ -91,7 +92,16 @@ export class BackupController {
if (!(await this.backup.backupFileExists(filename))) {
throw new HttpException({ error: 'Backup not found' }, 404);
}
res.download(this.backup.backupFilePath(filename), filename);
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')
+12
View File
@@ -10,6 +10,7 @@ import { invalidatePermissionsCache } from '../permissions/permissions-cache';
import { pluginsCodeRoot, pluginsDataRoot } from '../plugins/paths';
import { stageExtractedPluginTrees, applyStagedRestoreNow } from '../plugins/plugin-backup';
import { snapshotAllPluginDataDbs } from '../plugins/host/plugin-data.service';
import type { Response } from 'express';
import type { StorageService } from '../storage/storage.service';
// ---------------------------------------------------------------------------
@@ -89,6 +90,17 @@ export function backupFileExists(storage: StorageService, filename: string): Pro
return storage.exists('backups', filename);
}
/**
* The codebase's only res.download becomes the storage equivalent: root-relative
* sendFile via sendToResponse, with res.download's attachment header rebuilt by
* hand (filenames are regex-gated ASCII — no encoding cases).
*/
export function sendBackupToResponse(storage: StorageService, filename: string, res: Response): Promise<void> {
return storage.sendToResponse('backups', filename, res, {
disposition: `attachment; filename="${filename}"`,
});
}
// ---------------------------------------------------------------------------
// Rate limiter state (shared across requests)
// ---------------------------------------------------------------------------
+2
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from '../storage/storage.service';
import * as svc from './backup.impl';
@@ -24,6 +25,7 @@ export class BackupService {
isValidBackupFilename(filename: string) { return svc.isValidBackupFilename(filename); }
backupFilePath(filename: string) { return svc.backupFilePath(filename); }
backupFileExists(filename: string) { return svc.backupFileExists(this.storage, filename); }
sendBackupToResponse(filename: string, res: Response) { return svc.sendBackupToResponse(this.storage, filename, res); }
checkRateLimit(key: string, maxAttempts: number, windowMs: number) { return svc.checkRateLimit(key, maxAttempts, windowMs); }
get rateWindow() { return svc.BACKUP_RATE_WINDOW; }