audiobookshelf/server/managers/BackupManager.js
Kevin Gatera c8f753c58c
keep postgres backup credentials out of command argv
Pass connection parts as individual pg_dump/pg_restore args and the
password via PGPASSWORD env so credentials never appear in argv,
execFile error messages, notifications, or the host process list.
Redact the password from any error output as a fallback, and reject
non-URI DATABASE_URL values with a clear error.
2026-08-02 22:35:01 -04:00

748 lines
26 KiB
JavaScript

const childProcess = require('child_process')
const sqlite3 = require('sqlite3')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const cron = require('../libs/nodeCron')
const fs = require('../libs/fsExtra')
const archiver = require('../libs/archiver')
const StreamZip = require('../libs/nodeStreamZip')
const fileUtils = require('../utils/fileUtils')
// Utils
const { getFileSize } = require('../utils/fileUtils')
const Backup = require('../objects/Backup')
const CacheManager = require('./CacheManager')
const NotificationManager = require('./NotificationManager')
class BackupManager {
constructor() {
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
this.scheduleTask = null
this.backups = []
}
get backupPath() {
return global.ServerSettings.backupPath
}
get backupPathEnvSet() {
return !!process.env.BACKUP_PATH
}
get backupSchedule() {
return global.ServerSettings.backupSchedule
}
get backupsToKeep() {
return global.ServerSettings.backupsToKeep || 2
}
get maxBackupSize() {
return global.ServerSettings.maxBackupSize || Infinity
}
get databaseBackupConfig() {
if (Database.isPostgresDialect()) {
return {
dialect: 'postgres',
entryName: 'absdatabase.postgres.dump'
}
}
return {
dialect: 'sqlite',
entryName: 'absdatabase.sqlite'
}
}
getBackupDialect(backup) {
if (backup.key === 'postgres') return 'postgres'
if (backup.key === 'sqlite' || !backup.key) return 'sqlite'
return null
}
getBackupEntryName(dialect) {
return dialect === 'postgres' ? 'absdatabase.postgres.dump' : 'absdatabase.sqlite'
}
async init() {
try {
const backupsDirExists = await fs.pathExists(this.backupPath)
if (!backupsDirExists) {
await fs.ensureDir(this.backupPath)
}
} catch (error) {
Logger.error(`[BackupManager] Failed to ensure backup directory at "${this.backupPath}": ${error.message}`)
throw new Error(`[BackupManager] Failed to ensure backup directory at "${this.backupPath}"`, { cause: error })
}
await this.loadBackups()
this.scheduleCron()
}
/**
* Reload backups after updating backup path
*/
async reload() {
Logger.info(`[BackupManager] Reloading backups with backup path "${this.backupPath}"`)
this.backups = []
await this.loadBackups()
this.updateCronSchedule()
}
scheduleCron() {
if (!this.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)
return
}
try {
var cronSchedule = this.backupSchedule
this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))
} catch (error) {
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.backupSchedule}`, error)
}
}
updateCronSchedule() {
if (this.scheduleTask && !this.backupSchedule) {
Logger.info(`[BackupManager] Disabling backup schedule`)
if (this.scheduleTask.stop) this.scheduleTask.stop()
this.scheduleTask = null
} else if (!this.scheduleTask && this.backupSchedule) {
Logger.info(`[BackupManager] Starting backup schedule ${this.backupSchedule}`)
this.scheduleCron()
} else if (this.backupSchedule) {
Logger.info(`[BackupManager] Restarting backup schedule ${this.backupSchedule}`)
if (this.scheduleTask.stop) this.scheduleTask.stop()
this.scheduleCron()
}
}
async uploadBackup(req, res) {
const backupFile = req.files.file
if (Path.extname(backupFile.name) !== '.audiobookshelf') {
Logger.error(`[BackupManager] Invalid backup file uploaded "${backupFile.name}"`)
return res.status(500).send('Invalid backup file')
}
const tempPath = Path.join(this.backupPath, fileUtils.sanitizeFilename(backupFile.name))
const success = await backupFile
.mv(tempPath)
.then(() => true)
.catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move backup file into backups directory')
}
const zip = new StreamZip.async({ file: tempPath })
let entries
try {
entries = await zip.entries()
} catch (error) {
// Not a valid zip file
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
}
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error('[BackupManager] Invalid backup - missing details entry')
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - missing details entry')
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - details entry too large')
}
let backup
try {
const data = await zip.entryData('details')
const details = data.toString('utf8').split('\n')
backup = new Backup({ details, fullPath: tempPath })
} catch (error) {
Logger.error(`[BackupManager] Invalid backup with no readable details file`, tempPath, error)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file. Missing readable details.')
}
const backupDialect = this.getBackupDialect(backup)
const databaseEntryName = this.getBackupEntryName(backupDialect)
if (!backupDialect || !entries[databaseEntryName]) {
Logger.error(`[BackupManager] Invalid backup with no ${databaseEntryName} file - unsupported database backup.`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send(`Invalid backup file. Does not include ${databaseEntryName}.`)
}
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
}
await zip.close().catch(() => {})
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
if (existingBackupIndex >= 0) {
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
this.backups.splice(existingBackupIndex, 1, backup)
} else {
this.backups.push(backup)
}
res.json({
backups: this.backups.map((b) => b.toJSON())
})
}
async requestCreateBackup(res) {
var backupSuccess = await this.runBackup()
if (backupSuccess) {
res.json({
backups: this.backups.map((b) => b.toJSON())
})
} else {
res.sendStatus(500)
}
}
/**
*
* @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()
const backupDialect = this.getBackupDialect(backup)
const currentDialect = Database.isPostgresDialect() ? 'postgres' : 'sqlite'
if (!backupDialect) {
await zip.close()
return res.status(500).send('Invalid backup file. Unsupported database backup format.')
}
if (backupDialect !== currentDialect) {
await zip.close()
return res.status(400).send(`Cannot apply a ${backupDialect} backup while using the ${currentDialect} database.`)
}
if (backupDialect === 'postgres') {
return this.requestApplyPostgresBackup(apiCacheManager, backup, zip, entries, res)
}
// Ensure backup has an absdatabase.sqlite file
if (!Object.keys(entries).includes(this.getBackupEntryName(backupDialect))) {
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()
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 fs.ensureDir(this.ItemsMetadataPath)
await zip.extract('metadata-items/', this.ItemsMetadataPath)
await fs.ensureDir(this.AuthorsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await zip.close()
// Reconnect db
await Database.reconnect()
// Reset api cache, set hooks again
await apiCacheManager.reset()
// Clear metadata cache
await CacheManager.purgeAll()
res.sendStatus(200)
// Triggers browser refresh for all clients
SocketAuthority.emitter('backup_applied')
}
async requestApplyPostgresBackup(apiCacheManager, backup, zip, entries, res) {
const databaseEntryName = this.getBackupEntryName('postgres')
if (!Object.keys(entries).includes(databaseEntryName)) {
Logger.error(`[BackupManager] Cannot apply Postgres backup ${backup.fullPath}`)
await zip.close()
return res.status(500).send(`Invalid backup file. Does not include ${databaseEntryName}.`)
}
const tempDumpPath = Path.join(global.ConfigPath, 'absdatabase-postgres-temp.dump')
let reconnected = false
let zipClosed = false
const closeZip = async () => {
if (zipClosed) return
zipClosed = true
await zip.close()
}
try {
await fs.remove(tempDumpPath)
await zip.extract(databaseEntryName, tempDumpPath)
if (!(await fs.pathExists(tempDumpPath))) {
await closeZip()
return res.status(500).send('Failed to extract Postgres database dump from backup')
}
await Database.disconnect()
await this.restorePostgresDb(tempDumpPath)
await fs.ensureDir(this.ItemsMetadataPath)
await zip.extract('metadata-items/', this.ItemsMetadataPath)
await fs.ensureDir(this.AuthorsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await closeZip()
await Database.reconnect()
reconnected = true
await apiCacheManager.reset()
await CacheManager.purgeAll()
res.sendStatus(200)
SocketAuthority.emitter('backup_applied')
} catch (error) {
Logger.error(`[BackupManager] Failed to apply Postgres backup`, error)
try {
await closeZip()
} catch (closeError) {
Logger.error(`[BackupManager] Failed to close Postgres backup archive`, closeError)
}
if (!reconnected) {
try {
await Database.reconnect()
} catch (reconnectError) {
Logger.error(`[BackupManager] Failed to reconnect after Postgres backup apply`, reconnectError)
}
}
return res.status(500).send(`Failed to apply Postgres backup: ${error?.message || 'Unknown Error'}`)
} finally {
await fs.remove(tempDumpPath)
}
}
async loadBackups() {
try {
const filesInDir = await fs.readdir(this.backupPath)
for (let i = 0; i < filesInDir.length; i++) {
const filename = filesInDir[i]
if (filename.endsWith('.audiobookshelf')) {
const fullFilePath = Path.join(this.backupPath, filename)
let zip = null
let data = null
try {
zip = new StreamZip.async({ file: fullFilePath })
const entries = await zip.entries()
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
await zip.close().catch(() => {})
continue
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
await zip.close().catch(() => {})
continue
}
data = await zip.entryData('details')
} catch (error) {
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
if (zip) await zip.close().catch(() => {})
continue
}
const details = data.toString('utf8').split('\n')
const backup = new Backup({ details, fullPath: fullFilePath })
const backupDialect = this.getBackupDialect(backup)
const databaseEntryName = this.getBackupEntryName(backupDialect)
if (!backupDialect || !Object.keys(await zip.entries()).includes(databaseEntryName)) {
Logger.error(`[BackupManager] Unsupported database backup format found "${backup.filename}"`)
await zip.close()
continue
}
if (!backup.serverVersion) {
// Backups before v2
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.filename}"`)
} else if (!backup.key) {
// Backups before sqlite migration
Logger.warn(`[BackupManager] Old unsupported backup was found "${backup.filename}" (pre sqlite migration)`)
}
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupWithId = this.backups.find((b) => b.id === backup.id)
if (existingBackupWithId) {
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
} else {
this.backups.push(backup)
}
Logger.debug(`[BackupManager] Backup found "${backup.id}"`)
await zip.close()
}
}
Logger.info(`[BackupManager] ${this.backups.length} Backups Found`)
} catch (error) {
Logger.error('[BackupManager] Failed to load backups', error)
}
}
async runBackup() {
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
Logger.info(`[BackupManager] Running Backup`)
const databaseBackupConfig = this.databaseBackupConfig
const newBackup = new Backup()
newBackup.setData(this.backupPath, databaseBackupConfig.dialect)
await fs.ensureDir(this.AuthorsMetadataPath)
// Create a database dump
const databaseBackupPath = await this.backupDatabase(newBackup).catch((error) => {
Logger.error(`[BackupManager] Failed to backup ${databaseBackupConfig.dialect} database`, error)
const errorMsg = error?.message || error || 'Unknown Error'
NotificationManager.onBackupFailed(errorMsg)
return false
})
if (!databaseBackupPath) {
return false
}
// Zip database dump, /metadata/items, and /metadata/authors folders
const zipResult = await this.zipBackup(databaseBackupPath, newBackup, databaseBackupConfig.entryName).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`)
const errorMsg = error?.message || error || 'Unknown Error'
NotificationManager.onBackupFailed(errorMsg)
return false
})
// Remove temporary database dump
await fs.remove(databaseBackupPath)
if (!zipResult) return false
Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)
newBackup.fileSize = await getFileSize(newBackup.fullPath)
const existingIndex = this.backups.findIndex((b) => b.id === newBackup.id)
if (existingIndex >= 0) {
this.backups.splice(existingIndex, 1, newBackup)
} else {
this.backups.push(newBackup)
}
// Check remove oldest backup
const removeOldest = this.backups.length > this.backupsToKeep
if (removeOldest) {
this.backups.sort((a, b) => a.createdAt - b.createdAt)
const oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup)
}
// Notification for backup successfully completed
NotificationManager.onBackupCompleted(newBackup, this.backups.length, removeOldest)
return true
}
backupDatabase(backup) {
return this.databaseBackupConfig.dialect === 'postgres' ? this.backupPostgresDb(backup) : this.backupSqliteDb(backup)
}
async removeBackup(backup) {
try {
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
await fs.remove(backup.fullPath)
this.backups = this.backups.filter((b) => b.id !== backup.id)
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
} catch (error) {
Logger.error(`[BackupManager] Failed to remove backup`, error)
}
}
/**
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
* @param {Backup} backup
*/
backupSqliteDb(backup) {
const dbFilePath = Path.join(global.ConfigPath, `absdatabase.${backup.id}.sqlite`)
return new Promise(async (resolve, reject) => {
let db
let sqliteBackup
let settled = false
const finish = (error, result) => {
if (settled) return
settled = true
if (db) {
db.close(() => {
if (error) reject(error)
else resolve(result)
})
} else if (error) {
reject(error)
} else {
resolve(result)
}
}
const pollBackup = async () => {
// Max time ~2 mins
for (let i = 0; i < 240; i++) {
if (sqliteBackup.completed) {
return finish(null, dbFilePath)
} else if (sqliteBackup.failed) {
return finish(sqliteBackup.message || 'Unknown failure reason')
}
await new Promise((r) => setTimeout(r, 500))
}
Logger.error(`[BackupManager] Backup sqlite timed out`)
finish('Backup timed out')
}
const startBackup = () => {
try {
sqliteBackup = db.backup(dbFilePath)
sqliteBackup.step(-1)
sqliteBackup.finish()
pollBackup().catch(finish)
} catch (error) {
finish(error)
}
}
db = new sqlite3.Database(Database.dbPath, (error) => {
if (error) return finish(error)
startBackup()
})
db.on('error', finish)
})
}
/**
* Build pg_dump/pg_restore connection arguments from DATABASE_URL without
* exposing credentials in argv. execFile error messages and the host process
* list include argv, so the password is passed via PGPASSWORD env instead.
*/
getPostgresConnection() {
let dbUrl
try {
dbUrl = new URL(Database.dbPath)
} catch (error) {
throw new Error('DATABASE_URL must be a valid postgres connection URI to run backups')
}
const args = ['--host', dbUrl.hostname, '--dbname', decodeURIComponent(dbUrl.pathname.replace(/^\//, ''))]
if (dbUrl.port) args.push('--port', dbUrl.port)
if (dbUrl.username) args.push('--username', decodeURIComponent(dbUrl.username))
// Redact both the percent-encoded and decoded password from any error output
const decodedPassword = dbUrl.password ? decodeURIComponent(dbUrl.password) : null
const secrets = dbUrl.password ? [dbUrl.password, decodedPassword] : []
const env = decodedPassword ? { ...process.env, PGPASSWORD: decodedPassword } : process.env
return { args, env, secrets }
}
backupPostgresDb(backup) {
const dbFilePath = Path.join(global.ConfigPath, `absdatabase.${backup.id}.postgres.dump`)
return this.runPostgresCommand('pg_dump', [
'--format=custom',
'--no-owner',
'--no-acl',
'--file',
dbFilePath
])
.then(() => dbFilePath)
.catch(async (error) => {
await fs.remove(dbFilePath)
throw error
})
}
restorePostgresDb(dbFilePath) {
return this.runPostgresCommand('pg_restore', [
'--clean',
'--if-exists',
'--exit-on-error',
'--single-transaction',
'--no-owner',
'--no-acl',
dbFilePath
])
}
runPostgresCommand(command, args) {
return new Promise((resolve, reject) => {
let connection
try {
connection = this.getPostgresConnection()
} catch (error) {
return reject(error)
}
const redact = (text) => {
if (typeof text !== 'string') return text
return connection.secrets.reduce((redacted, secret) => redacted.split(secret).join('***'), text)
}
const options = {
maxBuffer: 10 * 1024 * 1024,
env: connection.env
}
childProcess.execFile(command, [...args, ...connection.args], options, (error, stdout, stderr) => {
if (error) {
error.message = redact(error.message)
if (error.cmd) error.cmd = redact(error.cmd)
error.stderr = redact(stderr)
return reject(error)
}
resolve({ stdout, stderr })
})
})
}
zipBackup(databaseBackupPath, backup, databaseEntryName = 'absdatabase.sqlite') {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(backup.fullPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info('[BackupManager]', archive.pointer() + ' total bytes')
resolve(true)
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
Logger.debug('Data has been drained')
})
output.on('finish', () => {
Logger.debug('Write Stream Finished')
})
output.on('error', (err) => {
Logger.debug('Write Stream Error', err)
reject(err)
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[BackupManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
if (this.maxBackupSize !== Infinity) {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
setTimeout(() => {
this.removeBackup(backup)
output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
}
}
})
// pipe archive data to the file
archive.pipe(output)
archive.file(databaseBackupPath, { name: databaseEntryName })
archive.directory(this.ItemsMetadataPath, 'metadata-items')
archive.directory(this.AuthorsMetadataPath, 'metadata-authors')
archive.append(backup.detailsString, { name: 'details' })
archive.finalize()
})
}
}
module.exports = BackupManager