mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 01:11:16 +03:00
refactor: permit any .sql(.gz) to be listed/restored
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -96,24 +96,28 @@
|
|||||||
let uploadProgress = $state(-1);
|
let uploadProgress = $state(-1);
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const [file] = await openFilePicker({ multiple: false });
|
try {
|
||||||
const formData = new FormData();
|
const [file] = await openFilePicker({ multiple: false });
|
||||||
formData.append('file', file);
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
await uploadRequest<MaintenanceUploadBackupDto>({
|
await uploadRequest<MaintenanceUploadBackupDto>({
|
||||||
url: getBaseUrl() + '/admin/maintenance/backups/upload',
|
url: getBaseUrl() + '/admin/maintenance/backups/upload',
|
||||||
data: formData,
|
data: formData,
|
||||||
onUploadProgress(event) {
|
onUploadProgress(event) {
|
||||||
uploadProgress = event.loaded / event.total;
|
uploadProgress = event.loaded / event.total;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadProgress = 1;
|
uploadProgress = 1;
|
||||||
|
|
||||||
const { backups: newList } = await listBackups();
|
const { backups: newList } = await listBackups();
|
||||||
backups = mapBackups(newList);
|
backups = mapBackups(newList);
|
||||||
|
} catch (error) {
|
||||||
uploadProgress = -1;
|
handleError(error, 'Could not upload backup, is it an .sql/.sql.gz file?');
|
||||||
|
} finally {
|
||||||
|
uploadProgress = -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user