mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
* fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
184 lines
4.6 KiB
Go
184 lines
4.6 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mattn/go-sqlite3"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
const (
|
|
backupPrefix = "navidrome_backup"
|
|
backupRegexString = "^" + backupPrefix + "_(.+)\\.db$"
|
|
)
|
|
|
|
var backupRegex = regexp.MustCompile(backupRegexString)
|
|
|
|
const backupSuffixLayout = "2006.01.02_15.04.05"
|
|
|
|
func backupPath(t time.Time) string {
|
|
return filepath.Join(
|
|
conf.Server.Backup.Path.MustPath(),
|
|
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
|
|
)
|
|
}
|
|
|
|
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
|
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
|
existingConn, err := Db().Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting existing connection: %w", err)
|
|
}
|
|
defer existingConn.Close()
|
|
|
|
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
|
// path would create an empty database and "restore" it over the live one.
|
|
if !isBackup {
|
|
// The driver splits the DSN at '?', so such a path would open a different file.
|
|
if strings.ContainsRune(path, '?') {
|
|
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
return fmt.Errorf("backup file not available: %w", err)
|
|
}
|
|
}
|
|
|
|
backupDb, err := sql.Open(Driver, path)
|
|
if err != nil {
|
|
return fmt.Errorf("opening backup database in '%s': %w", path, err)
|
|
}
|
|
defer backupDb.Close()
|
|
|
|
backupConn, err := backupDb.Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting backup connection: %w", err)
|
|
}
|
|
defer backupConn.Close()
|
|
|
|
err = existingConn.Raw(func(existing any) error {
|
|
return backupConn.Raw(func(backup any) error {
|
|
var sourceOk, destOk bool
|
|
var sourceConn, destConn *sqlite3.SQLiteConn
|
|
|
|
if isBackup {
|
|
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
|
|
destConn, destOk = backup.(*sqlite3.SQLiteConn)
|
|
} else {
|
|
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
|
|
destConn, destOk = existing.(*sqlite3.SQLiteConn)
|
|
}
|
|
|
|
if !sourceOk {
|
|
return fmt.Errorf("error trying to convert source to sqlite connection")
|
|
}
|
|
if !destOk {
|
|
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
|
}
|
|
|
|
backupOp, err := destConn.Backup("main", sourceConn, "main")
|
|
if err != nil {
|
|
return fmt.Errorf("error starting sqlite backup: %w", err)
|
|
}
|
|
defer backupOp.Close()
|
|
|
|
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
|
|
// This will lock out other writes that could happen at the same time
|
|
done, err := backupOp.Step(-1)
|
|
if err != nil {
|
|
return fmt.Errorf("error during backup step: %w", err)
|
|
}
|
|
if !done {
|
|
return fmt.Errorf("backup not done with step -1")
|
|
}
|
|
|
|
err = backupOp.Finish()
|
|
if err != nil {
|
|
return fmt.Errorf("error finishing backup: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func Backup(ctx context.Context) (string, error) {
|
|
destPath := backupPath(time.Now())
|
|
log.Debug(ctx, "Creating backup", "path", destPath)
|
|
err := backupOrRestore(ctx, true, destPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return destPath, nil
|
|
}
|
|
|
|
func Restore(ctx context.Context, path string) error {
|
|
log.Debug(ctx, "Restoring backup", "path", path)
|
|
return backupOrRestore(ctx, false, path)
|
|
}
|
|
|
|
func Prune(ctx context.Context) (int, error) {
|
|
backupDir, err := conf.Server.Backup.Path.Path()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("backup directory not available: %w", err)
|
|
}
|
|
files, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
|
|
}
|
|
|
|
var backupTimes []time.Time
|
|
|
|
for _, file := range files {
|
|
if !file.IsDir() {
|
|
submatch := backupRegex.FindStringSubmatch(file.Name())
|
|
if len(submatch) == 2 {
|
|
timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
|
|
if err == nil {
|
|
backupTimes = append(backupTimes, timestamp)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(backupTimes) <= conf.Server.Backup.Count {
|
|
return 0, nil
|
|
}
|
|
|
|
slices.SortFunc(backupTimes, func(a, b time.Time) int {
|
|
return b.Compare(a)
|
|
})
|
|
|
|
pruneCount := 0
|
|
var errs []error
|
|
|
|
for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] {
|
|
log.Debug(ctx, "Pruning backup", "time", timeToPrune)
|
|
path := backupPath(timeToPrune)
|
|
err = os.Remove(path)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
} else {
|
|
pruneCount++
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
err = errors.Join(errs...)
|
|
log.Error(ctx, "Failed to delete one or more files", "errors", err)
|
|
}
|
|
|
|
return pruneCount, err
|
|
}
|