From a2b2a2d060e9c5392df21dbb17a7ac7b6eb2ad8c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 16 Mar 2024 15:12:33 -0500 Subject: [PATCH] Fix:Applying backup not properly overwriting existing sqlite file - Fixed resetting api cache on backup - Added loading indicator in backups table - Fixed apply backup api not responding with 200 http status code - Added additional logging and failsafes --- client/components/tables/BackupsTable.vue | 14 ++++-- server/Database.js | 1 - server/controllers/BackupController.js | 7 ++- server/managers/ApiCacheManager.js | 10 +++++ server/managers/BackupManager.js | 54 ++++++++++++++++++++++- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/client/components/tables/BackupsTable.vue b/client/components/tables/BackupsTable.vue index 08692a4d..bd3d074a 100644 --- a/client/components/tables/BackupsTable.vue +++ b/client/components/tables/BackupsTable.vue @@ -1,5 +1,5 @@ @@ -64,6 +68,7 @@ export default { showConfirmApply: false, selectedBackup: null, isBackingUp: false, + isApplyingBackup: false, processing: false, backups: [] } @@ -85,19 +90,21 @@ export default { }, confirm() { this.showConfirmApply = false + this.isApplyingBackup = true this.$axios .$get(`/api/backups/${this.selectedBackup.id}/apply`) .then(() => { - this.isBackingUp = false location.replace('/config/backups?backup=1') }) .catch((error) => { - this.isBackingUp = false console.error('Failed to apply backup', error) const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed this.$toast.error(errorMsg) }) + .finally(() => { + this.isApplyingBackup = false + }) }, deleteBackupClick(backup) { if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) { @@ -180,7 +187,6 @@ export default { this.loadBackups() if (this.$route.query.backup) { this.$toast.success('Backup applied successfully') - this.$router.replace('/config') } } } diff --git a/server/Database.js b/server/Database.js index e3fabe1f..64dc518e 100644 --- a/server/Database.js +++ b/server/Database.js @@ -217,7 +217,6 @@ class Database { async disconnect() { Logger.info(`[Database] Disconnecting sqlite db`) await this.sequelize.close() - this.sequelize = null } /** diff --git a/server/controllers/BackupController.js b/server/controllers/BackupController.js index 8104623e..bd6caa0b 100644 --- a/server/controllers/BackupController.js +++ b/server/controllers/BackupController.js @@ -49,8 +49,13 @@ class BackupController { res.sendFile(req.backup.fullPath) } + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ apply(req, res) { - this.backupManager.requestApplyBackup(req.backup, res) + this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res) } middleware(req, res, next) { diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index 1af069f3..bb99b8cb 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -22,6 +22,16 @@ class ApiCacheManager { this.cache.clear() } + /** + * Reset hooks and clear cache. Used when applying backups + */ + reset() { + Logger.info(`[ApiCacheManager] Resetting cache`) + + this.init() + this.cache.clear() + } + get middleware() { return (req, res, next) => { const key = { user: req.user.username, url: req.url } diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js index ef8ed643..40a74f14 100644 --- a/server/managers/BackupManager.js +++ b/server/managers/BackupManager.js @@ -146,23 +146,73 @@ class BackupManager { } } - async requestApplyBackup(backup, res) { + /** + * + * @param {import('./ApiCacheManager')} apiCacheManager + * @param {Backup} backup + * @param {import('express').Response} res + */ + async requestApplyBackup(apiCacheManager, backup, res) { + Logger.info(`[BackupManager] Applying backup at "${backup.fullPath}"`) + const zip = new StreamZip.async({ file: backup.fullPath }) const entries = await zip.entries() + + // Ensure backup has an absdatabase.sqlite file if (!Object.keys(entries).includes('absdatabase.sqlite')) { Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`) + await zip.close() return res.status(500).send('Invalid backup file. Does not include absdatabase.sqlite. This might be from an older Audiobookshelf server.') } await Database.disconnect() - await zip.extract('absdatabase.sqlite', global.ConfigPath) + const dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite') + const tempDbPath = Path.join(global.ConfigPath, 'absdatabase-temp.sqlite') + + // Extract backup sqlite file to temporary path + await zip.extract('absdatabase.sqlite', tempDbPath) + Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`) + + // Verify extract - Abandon backup if sqlite file did not extract + if (!await fs.pathExists(tempDbPath)) { + Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`) + await zip.close() + await Database.reconnect() + return res.status(500).send('Failed to extract sqlite db from backup') + } + + // Attempt to remove existing db file + try { + await fs.remove(dbPath) + } catch (error) { + // Abandon backup and remove extracted sqlite file if unable to remove existing db file + Logger.error(`[BackupManager] Unable to overwrite existing db file - abandon backup apply and reconnect db`, error) + await fs.remove(tempDbPath) + await zip.close() + await Database.reconnect() + return res.status(500).send(`Failed to overwrite sqlite db: ${error?.message || 'Unknown Error'}`) + } + + // Rename temp db + await fs.move(tempDbPath, dbPath) + Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`) + + // Extract /metadata/items and /metadata/authors folders await zip.extract('metadata-items/', this.ItemsMetadataPath) await zip.extract('metadata-authors/', this.AuthorsMetadataPath) + await zip.close() + // Reconnect db await Database.reconnect() + // Reset api cache, set hooks again + await apiCacheManager.reset() + + res.sendStatus(200) + + // Triggers browser refresh for all clients SocketAuthority.emitter('backup_applied') }