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
This commit is contained in:
advplyr 2024-03-16 15:12:33 -05:00
parent 88f9533b37
commit a2b2a2d060
5 changed files with 78 additions and 8 deletions

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="text-center mt-4"> <div class="text-center mt-4 relative">
<div class="flex py-4"> <div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input> <ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
<div class="flex-grow" /> <div class="flex-grow" />
@ -54,6 +54,10 @@
</div> </div>
</div> </div>
</prompt-dialog> </prompt-dialog>
<div v-if="isApplyingBackup" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md">
<ui-loading-indicator />
</div>
</div> </div>
</template> </template>
@ -64,6 +68,7 @@ export default {
showConfirmApply: false, showConfirmApply: false,
selectedBackup: null, selectedBackup: null,
isBackingUp: false, isBackingUp: false,
isApplyingBackup: false,
processing: false, processing: false,
backups: [] backups: []
} }
@ -85,19 +90,21 @@ export default {
}, },
confirm() { confirm() {
this.showConfirmApply = false this.showConfirmApply = false
this.isApplyingBackup = true
this.$axios this.$axios
.$get(`/api/backups/${this.selectedBackup.id}/apply`) .$get(`/api/backups/${this.selectedBackup.id}/apply`)
.then(() => { .then(() => {
this.isBackingUp = false
location.replace('/config/backups?backup=1') location.replace('/config/backups?backup=1')
}) })
.catch((error) => { .catch((error) => {
this.isBackingUp = false
console.error('Failed to apply backup', error) console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
}) })
.finally(() => {
this.isApplyingBackup = false
})
}, },
deleteBackupClick(backup) { deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) { if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
@ -180,7 +187,6 @@ export default {
this.loadBackups() this.loadBackups()
if (this.$route.query.backup) { if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully') this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
} }
} }
} }

View File

@ -217,7 +217,6 @@ class Database {
async disconnect() { async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`) Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close() await this.sequelize.close()
this.sequelize = null
} }
/** /**

View File

@ -49,8 +49,13 @@ class BackupController {
res.sendFile(req.backup.fullPath) res.sendFile(req.backup.fullPath)
} }
/**
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
apply(req, res) { apply(req, res) {
this.backupManager.requestApplyBackup(req.backup, res) this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
} }
middleware(req, res, next) { middleware(req, res, next) {

View File

@ -22,6 +22,16 @@ class ApiCacheManager {
this.cache.clear() 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() { get middleware() {
return (req, res, next) => { return (req, res, next) => {
const key = { user: req.user.username, url: req.url } const key = { user: req.user.username, url: req.url }

View File

@ -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 zip = new StreamZip.async({ file: backup.fullPath })
const entries = await zip.entries() const entries = await zip.entries()
// Ensure backup has an absdatabase.sqlite file
if (!Object.keys(entries).includes('absdatabase.sqlite')) { if (!Object.keys(entries).includes('absdatabase.sqlite')) {
Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`) 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.') 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 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-items/', this.ItemsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath) await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await zip.close()
// Reconnect db
await Database.reconnect() 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') SocketAuthority.emitter('backup_applied')
} }