Skip to content

Commit

Permalink
Fix:Applying backup not properly overwriting existing sqlite file
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
advplyr committed Mar 16, 2024
1 parent 88f9533 commit a2b2a2d
Show file tree
Hide file tree
Showing 5 changed files with 78 additions and 8 deletions.
14 changes: 10 additions & 4 deletions client/components/tables/BackupsTable.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="text-center mt-4">
<div class="text-center mt-4 relative">
<div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
<div class="flex-grow" />
Expand Down Expand Up @@ -54,6 +54,10 @@
</div>
</div>
</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>
</template>

Expand All @@ -64,6 +68,7 @@ export default {
showConfirmApply: false,
selectedBackup: null,
isBackingUp: false,
isApplyingBackup: false,
processing: false,
backups: []
}
Expand All @@ -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)]))) {
Expand Down Expand Up @@ -180,7 +187,6 @@ export default {
this.loadBackups()
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
}
}
}
Expand Down
1 change: 0 additions & 1 deletion server/Database.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ class Database {
async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close()
this.sequelize = null
}

/**
Expand Down
7 changes: 6 additions & 1 deletion server/controllers/BackupController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions server/managers/ApiCacheManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
54 changes: 52 additions & 2 deletions server/managers/BackupManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down

0 comments on commit a2b2a2d

Please sign in to comment.