diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index e419f32c3d..360295d82b 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -30,7 +30,6 @@ export class MaintenanceStatusResponseDto { export class MaintenanceListBackupsResponseDto { backups!: string[]; - failedBackups!: string[]; } export class MaintenanceUploadBackupDto { diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index 559c0b106e..620a24b41e 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -275,8 +275,8 @@ export class MaintenanceWorkerService { }); } - async listBackups(): Promise> { - return listBackups(this.backupRepos); + async listBackups(): Promise<{ backups: string[] }> { + return { backups: await listBackups(this.backupRepos) }; } async deleteBackup(filename: string): Promise { diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 2015f989a0..c8ae55671a 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -5,7 +5,12 @@ import { OnEvent, OnJob } from 'src/decorators'; import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { createBackup, listBackups, UnsupportedPostgresError } from 'src/utils/backups'; +import { + createBackup, + isFailedBackupName, + isValidRoutineBackupName, + UnsupportedPostgresError, +} from 'src/utils/backups'; import { handlePromiseError } from 'src/utils/misc'; @Injectable() @@ -50,7 +55,9 @@ export class BackupService extends BaseService { } = await this.getConfig({ withCache: false }); const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); - const { backups, failedBackups } = await listBackups(this.backupRepos); + const files = await this.storageRepository.readdir(backupsFolder); + const backups = files.filter(isValidRoutineBackupName); + const failedBackups = files.filter(isFailedBackupName); const toDelete = backups.slice(config.keepLastAmount); toDelete.push(...failedBackups); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index 19c6858e44..69551b308a 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -75,8 +75,8 @@ export class MaintenanceService extends BaseService { * Backups */ - async listBackups(): Promise> { - return listBackups(this.backupRepos); + async listBackups(): Promise<{ backups: string[] }> { + return { backups: await listBackups(this.backupRepos) }; } async deleteBackup(filename: string): Promise { diff --git a/server/src/utils/backups.ts b/server/src/utils/backups.ts index eef799bdf6..6215518c65 100644 --- a/server/src/utils/backups.ts +++ b/server/src/utils/backups.ts @@ -16,14 +16,18 @@ import { ProcessRepository } from 'src/repositories/process.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; export function isValidBackupName(filename: string) { - const oldBackupStyle = filename.match(/immich-db-backup-\d+\.sql\.gz$/); + return filename.match(/^[\d\w-.]+\.sql(?:\.gz)?$/); +} + +export function isValidRoutineBackupName(filename: string) { + const oldBackupStyle = filename.match(/^immich-db-backup-\d+\.sql\.gz$/); //immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz - const newBackupStyle = filename.match(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/); + const newBackupStyle = filename.match(/^immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/); return oldBackupStyle || newBackupStyle; } export function isFailedBackupName(filename: string) { - return filename.match(/immich-db-backup-.*\.sql\.gz\.tmp$/); + return filename.match(/^immich-db-backup-.*\.sql\.gz\.tmp$/); } type BackupRepos = { @@ -127,7 +131,7 @@ export async function buildPostgresLaunchArguments( export async function createBackup( { logger, storage, process: processRepository, ...pgRepos }: BackupRepos, - filenameSuffix: string = '', + filenamePrefix: string = '', ): Promise { logger.debug(`Database Backup Started`); @@ -140,7 +144,7 @@ export async function createBackup( const backupFilePath = join( StorageCore.getBaseFolder(StorageFolder.Backups), - `immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}${filenameSuffix}.sql.gz.tmp`, + `${filenamePrefix}immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz.tmp`, ); try { @@ -176,10 +180,7 @@ export async function restoreBackup( let complete = false; try { - if (!isValidBackupName(filename) && !filename.startsWith('development-')) { - // if we want to allow custom file names - // replace this with a check that we aren't - // traversing out of the backup directory + if (!isValidBackupName(filename)) { throw new Error('Invalid backup file format!'); } @@ -193,7 +194,7 @@ export async function restoreBackup( progressCb?.('backup', 0.05); - await createBackup({ logger, storage, process: processRepository, ...pgRepos }, '-maintenance'); + await createBackup({ logger, storage, process: processRepository, ...pgRepos }, 'restore-point-'); logger.log(`Database Restore Starting. Database Version: ${databaseMajorVersion}`); @@ -256,24 +257,23 @@ export async function deleteBackup({ storage }: Pick, fi await storage.unlink(path.join(backupsFolder, filename)); } -export async function listBackups({ - storage, -}: Pick): Promise> { +export async function listBackups({ storage }: Pick): Promise { const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const files = await storage.readdir(backupsFolder); - - return { - backups: files - .filter((fn) => isValidBackupName(fn) || fn.startsWith('development-')) - .sort() - .toReversed(), - failedBackups: files.filter((fn) => isFailedBackupName(fn)), - }; + return files + .filter(isValidBackupName) + .sort((a, b) => (a.startsWith('uploaded-') !== b.startsWith('uploaded-') ? 1 : a.localeCompare(b))) + .toReversed(); } export async function uploadBackup(file: Express.Multer.File): Promise { const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); - const path = join(backupsFolder, file.originalname); + const fn = file.originalname; + if (!isValidBackupName(fn)) { + throw new BadRequestException('Not a valid backup name!'); + } + + const path = join(backupsFolder, `uploaded-${fn}`); try { await stat(path); diff --git a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte index 2d9c7ebd97..92f3d51651 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte @@ -96,24 +96,28 @@ let uploadProgress = $state(-1); async function upload() { - const [file] = await openFilePicker({ multiple: false }); - const formData = new FormData(); - formData.append('file', file); + try { + const [file] = await openFilePicker({ multiple: false }); + const formData = new FormData(); + formData.append('file', file); - await uploadRequest({ - url: getBaseUrl() + '/admin/maintenance/backups/upload', - data: formData, - onUploadProgress(event) { - uploadProgress = event.loaded / event.total; - }, - }); + await uploadRequest({ + url: getBaseUrl() + '/admin/maintenance/backups/upload', + data: formData, + onUploadProgress(event) { + uploadProgress = event.loaded / event.total; + }, + }); - uploadProgress = 1; + uploadProgress = 1; - const { backups: newList } = await listBackups(); - backups = mapBackups(newList); - - uploadProgress = -1; + const { backups: newList } = await listBackups(); + backups = mapBackups(newList); + } catch (error) { + handleError(error, 'Could not upload backup, is it an .sql/.sql.gz file?'); + } finally { + uploadProgress = -1; + } }