🏡 index : quine-global/quintodrome.git

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 openBackupFile(path string) (*sql.DB, error) {
	Db() // Registers Driver, which the CLI commands haven't done at this point.
	backupDb, err := sql.Open(Driver, path)
	if err != nil {
		return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
	}
	return backupDb, nil
}

func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
	// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
	destConn, err := dest.Conn(ctx)
	if err != nil {
		return fmt.Errorf("getting destination connection: %w", err)
	}
	defer destConn.Close()

	sourceConn, err := source.Conn(ctx)
	if err != nil {
		return fmt.Errorf("getting source connection: %w", err)
	}
	defer sourceConn.Close()

	return destConn.Raw(func(rawDest any) error {
		return sourceConn.Raw(func(rawSource any) error {
			sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
			if !destOk {
				return fmt.Errorf("error trying to convert destination to sqlite connection")
			}
			sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
			if !sourceOk {
				return fmt.Errorf("error trying to convert source to sqlite connection")
			}

			backupOp, err := sqliteDest.Backup("main", sqliteSource, "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
		})
	})
}

// checkRestoreSource runs sanity checks on a file about to be restored over the live
// database. They catch the common ways a backup goes bad, not every kind of damage.
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
	// SQLite opens an empty file as an empty database, and restoring it would wipe the live one.
	// user and media_file exist since the first migration, so backups of any age have them.
	var tables int
	err := backupDb.QueryRowContext(ctx,
		"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
	if err != nil {
		return fmt.Errorf("reading backup file '%s': %w", path, err)
	}
	if tables != 3 {
		return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
	}

	// The table can be there with nothing in it, or with only the row goose adds when it creates it.
	var applied int
	err = backupDb.QueryRowContext(ctx,
		"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
	if err != nil {
		return fmt.Errorf("reading backup file '%s': %w", path, err)
	}
	if applied == 0 {
		return fmt.Errorf("backup file '%s' has no applied migrations", path)
	}

	// The queries above only read the first pages. Both checks walk the whole file, and
	// integrity_check also compares indexes to their tables, which is much slower.
	check := "PRAGMA quick_check(1)"
	if integrityCheck {
		check = "PRAGMA integrity_check(1)"
	}
	var result string
	if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
		return fmt.Errorf("checking backup file '%s': %w", path, err)
	}
	if result != "ok" {
		return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
	}
	return nil
}

func Backup(ctx context.Context) (string, error) {
	destPath := backupPath(time.Now())
	log.Debug(ctx, "Creating backup", "path", destPath)
	backupDb, err := openBackupFile(destPath)
	if err != nil {
		return "", err
	}
	defer backupDb.Close()

	if err := copyDatabase(ctx, backupDb, Db()); err != nil {
		return "", err
	}

	return destPath, nil
}

func Restore(ctx context.Context, path string, integrityCheck bool) error {
	log.Debug(ctx, "Restoring backup", "path", path)

	// 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)
	}
	// 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 _, err := os.Stat(path); err != nil {
		return fmt.Errorf("backup file not available: %w", err)
	}

	backupDb, err := openBackupFile(path)
	if err != nil {
		return err
	}
	defer backupDb.Close()

	if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
		return err
	}
	return copyDatabase(ctx, Db(), backupDb)
}

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
}