mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-08-03 15:11:44 +00:00
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.
748 lines
26 KiB
JavaScript
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
|