refactor: permit any .sql(.gz) to be listed/restored

This commit is contained in:
izzy
2025-11-21 14:24:35 +00:00
parent 19ba23056c
commit a3c6d71a58
6 changed files with 54 additions and 44 deletions

View File

@@ -30,7 +30,6 @@ export class MaintenanceStatusResponseDto {
export class MaintenanceListBackupsResponseDto { export class MaintenanceListBackupsResponseDto {
backups!: string[]; backups!: string[];
failedBackups!: string[];
} }
export class MaintenanceUploadBackupDto { export class MaintenanceUploadBackupDto {

View File

@@ -275,8 +275,8 @@ export class MaintenanceWorkerService {
}); });
} }
async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> { async listBackups(): Promise<{ backups: string[] }> {
return listBackups(this.backupRepos); return { backups: await listBackups(this.backupRepos) };
} }
async deleteBackup(filename: string): Promise<void> { async deleteBackup(filename: string): Promise<void> {

View File

@@ -5,7 +5,12 @@ import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; 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'; import { handlePromiseError } from 'src/utils/misc';
@Injectable() @Injectable()
@@ -50,7 +55,9 @@ export class BackupService extends BaseService {
} = await this.getConfig({ withCache: false }); } = await this.getConfig({ withCache: false });
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); 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); const toDelete = backups.slice(config.keepLastAmount);
toDelete.push(...failedBackups); toDelete.push(...failedBackups);

View File

@@ -75,8 +75,8 @@ export class MaintenanceService extends BaseService {
* Backups * Backups
*/ */
async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> { async listBackups(): Promise<{ backups: string[] }> {
return listBackups(this.backupRepos); return { backups: await listBackups(this.backupRepos) };
} }
async deleteBackup(filename: string): Promise<void> { async deleteBackup(filename: string): Promise<void> {

View File

@@ -16,14 +16,18 @@ import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
export function isValidBackupName(filename: string) { 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 //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; return oldBackupStyle || newBackupStyle;
} }
export function isFailedBackupName(filename: string) { 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 = { type BackupRepos = {
@@ -127,7 +131,7 @@ export async function buildPostgresLaunchArguments(
export async function createBackup( export async function createBackup(
{ logger, storage, process: processRepository, ...pgRepos }: BackupRepos, { logger, storage, process: processRepository, ...pgRepos }: BackupRepos,
filenameSuffix: string = '', filenamePrefix: string = '',
): Promise<void> { ): Promise<void> {
logger.debug(`Database Backup Started`); logger.debug(`Database Backup Started`);
@@ -140,7 +144,7 @@ export async function createBackup(
const backupFilePath = join( const backupFilePath = join(
StorageCore.getBaseFolder(StorageFolder.Backups), 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 { try {
@@ -176,10 +180,7 @@ export async function restoreBackup(
let complete = false; let complete = false;
try { try {
if (!isValidBackupName(filename) && !filename.startsWith('development-')) { if (!isValidBackupName(filename)) {
// if we want to allow custom file names
// replace this with a check that we aren't
// traversing out of the backup directory
throw new Error('Invalid backup file format!'); throw new Error('Invalid backup file format!');
} }
@@ -193,7 +194,7 @@ export async function restoreBackup(
progressCb?.('backup', 0.05); 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}`); logger.log(`Database Restore Starting. Database Version: ${databaseMajorVersion}`);
@@ -256,24 +257,23 @@ export async function deleteBackup({ storage }: Pick<BackupRepos, 'storage'>, fi
await storage.unlink(path.join(backupsFolder, filename)); await storage.unlink(path.join(backupsFolder, filename));
} }
export async function listBackups({ export async function listBackups({ storage }: Pick<BackupRepos, 'storage'>): Promise<string[]> {
storage,
}: Pick<BackupRepos, 'storage'>): Promise<Record<'backups' | 'failedBackups', string[]>> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await storage.readdir(backupsFolder); const files = await storage.readdir(backupsFolder);
return files
return { .filter(isValidBackupName)
backups: files .sort((a, b) => (a.startsWith('uploaded-') !== b.startsWith('uploaded-') ? 1 : a.localeCompare(b)))
.filter((fn) => isValidBackupName(fn) || fn.startsWith('development-')) .toReversed();
.sort()
.toReversed(),
failedBackups: files.filter((fn) => isFailedBackupName(fn)),
};
} }
export async function uploadBackup(file: Express.Multer.File): Promise<void> { export async function uploadBackup(file: Express.Multer.File): Promise<void> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); 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 { try {
await stat(path); await stat(path);

View File

@@ -96,6 +96,7 @@
let uploadProgress = $state(-1); let uploadProgress = $state(-1);
async function upload() { async function upload() {
try {
const [file] = await openFilePicker({ multiple: false }); const [file] = await openFilePicker({ multiple: false });
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@@ -112,9 +113,12 @@
const { backups: newList } = await listBackups(); const { backups: newList } = await listBackups();
backups = mapBackups(newList); backups = mapBackups(newList);
} catch (error) {
handleError(error, 'Could not upload backup, is it an .sql/.sql.gz file?');
} finally {
uploadProgress = -1; uploadProgress = -1;
} }
}
</script> </script>
<Stack gap={2} class="mt-4 text-left"> <Stack gap={2} class="mt-4 text-left">