diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index 3274f27eb3..d5e42d2638 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -450,17 +450,25 @@ describe(MaintenanceWorkerService.name, () => { }); describe('deleteBackup', () => { + it('should reject invalid file names', async () => { + await expect(sut.deleteBackup(['filename'])).rejects.toThrowError( + new BadRequestException('Invalid backup name!'), + ); + }); + it('should unlink the target file', async () => { - await sut.deleteBackup('filename'); + await sut.deleteBackup(['filename.sql']); expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); - expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); + expect(mocks.storage.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`, + ); }); }); describe('uploadBackup', () => { it('should reject invalid file names', async () => { await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError( - new BadRequestException('Not a valid backup name!'), + new BadRequestException('Invalid backup name!'), ); }); diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index f6f91c8147..21f4811a86 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -1,10 +1,9 @@ -import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { parse } from 'cookie'; import { NextFunction, Request, Response } from 'express'; import { jwtVerify } from 'jose'; import { readFileSync } from 'node:fs'; import { IncomingHttpHeaders } from 'node:http'; -import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { MaintenanceAuthDto, @@ -13,14 +12,7 @@ import { SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ServerConfigDto } from 'src/dtos/server.dto'; -import { - CacheControl, - DatabaseLock, - ImmichCookie, - MaintenanceAction, - StorageFolder, - SystemMetadataKey, -} from 'src/enum'; +import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -34,7 +26,7 @@ import { type BaseService as _BaseService } from 'src/services/base.service'; import { type DatabaseBackupService as _DatabaseBackupService } from 'src/services/database-backup.service'; import { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; -import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; +import { deleteBackups, downloadBackup, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; import { ImmichFileResponse } from 'src/utils/file'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance'; @@ -185,8 +177,8 @@ export class MaintenanceWorkerService { /** * {@link _DatabaseBackupService.deleteBackup} */ - async deleteBackup(filename: string): Promise { - return deleteBackup(this.backupRepos, filename); + async deleteBackup(files: string[]): Promise { + return deleteBackups(this.backupRepos, files); } /** @@ -200,18 +192,7 @@ export class MaintenanceWorkerService { * {@link _DatabaseBackupService.downloadBackup} */ downloadBackup(fileName: string): ImmichFileResponse { - if (!isValidBackupName(fileName)) { - throw new BadRequestException('Invalid backup name!'); - } - - const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName); - - return { - path, - fileName, - cacheControl: CacheControl.PrivateWithoutCache, - contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', - }; + return downloadBackup(fileName); } private get backupRepos() { diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 809196416b..3c01ecad22 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -38,17 +38,25 @@ describe(MaintenanceService.name, () => { }); describe('deleteBackup', () => { + it('should reject invalid file names', async () => { + await expect(sut.deleteBackup(['filename'])).rejects.toThrowError( + new BadRequestException('Invalid backup name!'), + ); + }); + it('should unlink the target file', async () => { - await sut.deleteBackup(['filename']); + await sut.deleteBackup(['filename.sql']); expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); - expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); + expect(mocks.storage.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`, + ); }); }); describe('uploadBackup', () => { it('should reject invalid file names', async () => { await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError( - new BadRequestException('Not a valid backup name!'), + new BadRequestException('Invalid backup name!'), ); }); diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 02c1bda93f..1dadc1c63b 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -1,9 +1,6 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { basename, join } from 'node:path'; -import { StorageCore } from 'src/cores/storage.core'; -import { CacheControl, StorageFolder } from 'src/enum'; +import { Injectable } from '@nestjs/common'; import { BaseService } from 'src/services/base.service'; -import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups'; +import { deleteBackups, downloadBackup, listBackups, uploadBackup } from 'src/utils/backups'; import { ImmichFileResponse } from 'src/utils/file'; /** @@ -15,12 +12,8 @@ export class DatabaseBackupService extends BaseService { return { backups: await listBackups(this.backupRepos) }; } - async deleteBackup(files: string[]): Promise { - if (files.some((filename) => !isValidBackupName(filename))) { - throw new BadRequestException('Invalid backup name!'); - } - - await Promise.all(files.map((filename) => deleteBackup(this.backupRepos, basename(filename)))); + deleteBackup(files: string[]): Promise { + return deleteBackups(this.backupRepos, files); } async uploadBackup(file: Express.Multer.File): Promise { @@ -28,18 +21,7 @@ export class DatabaseBackupService extends BaseService { } downloadBackup(fileName: string): ImmichFileResponse { - if (!isValidBackupName(fileName)) { - throw new BadRequestException('Invalid backup name!'); - } - - const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName); - - return { - path, - fileName, - cacheControl: CacheControl.PrivateWithoutCache, - contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', - }; + return downloadBackup(fileName); } private get backupRepos() { diff --git a/server/src/utils/backups.ts b/server/src/utils/backups.ts index e18133dab5..d8f96a4cef 100644 --- a/server/src/utils/backups.ts +++ b/server/src/utils/backups.ts @@ -7,7 +7,7 @@ import { pipeline } from 'node:stream/promises'; import semver from 'semver'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { StorageFolder } from 'src/enum'; +import { CacheControl, StorageFolder } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -266,9 +266,14 @@ export async function restoreBackup( logger.log(`Database Restore Success`); } -export async function deleteBackup({ storage }: Pick, filename: string): Promise { +export async function deleteBackups({ storage }: Pick, files: string[]): Promise { const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); - await storage.unlink(path.join(backupsFolder, filename)); + + if (files.some((filename) => !isValidBackupName(filename))) { + throw new BadRequestException('Invalid backup name!'); + } + + await Promise.all(files.map((filename) => storage.unlink(path.join(backupsFolder, filename)))); } export async function listBackups({ storage }: Pick): Promise { @@ -287,13 +292,28 @@ export async function uploadBackup( const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const fn = basename(file.originalname); if (!isValidBackupName(fn)) { - throw new BadRequestException('Not a valid backup name!'); + throw new BadRequestException('Invalid backup name!'); } const path = join(backupsFolder, `uploaded-${fn}`); await storage.createOrOverwriteFile(path, file.buffer); } +export function downloadBackup(fileName: string) { + if (!isValidBackupName(fileName)) { + throw new BadRequestException('Invalid backup name!'); + } + + const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName); + + return { + path, + fileName, + cacheControl: CacheControl.PrivateWithoutCache, + contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', + }; +} + function createSqlProgressStreams(cb: (progress: number) => void) { const STDIN_START_MARKER = new TextEncoder().encode('FROM stdin'); const STDIN_END_MARKER = new TextEncoder().encode(String.raw`\.`);