// Package backup makes, lists and restores backups of playlink's database // (settings, library pairs, scan results and history, not music files). // // A backup is a zip holding a consistent snapshot of the database (SQLite // "VACUUM INTO", safe while the app runs) and a small backup.json. Restoring // stages the database next to the live one; it is swapped in at the next // start (see ApplyStaged), and the replaced database is kept. package backup import ( "archive/zip" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/cdnutter/playlink/internal/store" ) const ( TypeManual = "manual" TypeScheduled = "scheduled" TypeAction = "action" // taken automatically before an action in live mode dbName = "playlink.db" stagedName = "playlink.db.restore" metaName = "backup.json" keepActions = 10 ) var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`) var ErrBadName = errors.New("not a Playlink backup file name") // Settings stored in the settings table. const ( KeyFolder = "backup_folder" KeyIntervalDays = "backup_interval_days" KeyRetentionDays = "backup_retention_days" KeyBeforeActions = "backup_before_actions" ) type Manager struct { st *store.Store dataDir string version string log *slog.Logger mu sync.Mutex // one backup or restore at a time } func New(st *store.Store, dataDir, version string, log *slog.Logger) *Manager { return &Manager{st: st, dataDir: dataDir, version: version, log: log} } // Config is the user-facing backup configuration. type Config struct { Folder string `json:"folder"` IntervalDays int `json:"interval_days"` // 0 = scheduled backups off RetentionDays int `json:"retention_days"` // scheduled backups older than this are removed BeforeActions bool `json:"before_actions"` } func (m *Manager) Config(ctx context.Context) Config { c := Config{ Folder: m.st.Setting(ctx, KeyFolder, filepath.Join(m.dataDir, "backups")), BeforeActions: m.st.Setting(ctx, KeyBeforeActions, "0") == "1", } c.IntervalDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyIntervalDays, "7")) c.RetentionDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyRetentionDays, "28")) return c } func (m *Manager) SetConfig(ctx context.Context, c Config) error { c.Folder = filepath.Clean(strings.TrimSpace(c.Folder)) if !filepath.IsAbs(c.Folder) { return errors.New("the backup folder must be an absolute path") } if c.IntervalDays < 0 || c.IntervalDays > 365 || c.RetentionDays < 1 || c.RetentionDays > 3650 { return errors.New("interval must be 0-365 days and retention 1-3650 days") } if err := os.MkdirAll(c.Folder, 0o750); err != nil { return fmt.Errorf("backup folder: %w", err) } probe := filepath.Join(c.Folder, ".playlink-write-test") if err := os.WriteFile(probe, nil, 0o600); err != nil { return fmt.Errorf("backup folder is not writable: %w", err) } _ = os.Remove(probe) before := "0" if c.BeforeActions { before = "1" } for k, v := range map[string]string{KeyFolder: c.Folder, KeyIntervalDays: strconv.Itoa(c.IntervalDays), KeyRetentionDays: strconv.Itoa(c.RetentionDays), KeyBeforeActions: before} { if err := m.st.SetSetting(ctx, k, v); err != nil { return err } } return nil } // Backup is one backup file. type Backup struct { Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` Created int64 `json:"created"` } type meta struct { App string `json:"app"` Version string `json:"version"` Schema int `json:"schema"` Type string `json:"type"` Reason string `json:"reason,omitempty"` Created int64 `json:"created"` } // Create writes a new backup and applies retention. func (m *Manager) Create(ctx context.Context, typ, reason string) (Backup, error) { m.mu.Lock() defer m.mu.Unlock() cfg := m.Config(ctx) if err := os.MkdirAll(cfg.Folder, 0o750); err != nil { return Backup{}, err } now := time.Now() name := fmt.Sprintf("playlink_backup_%s_%s.zip", typ, now.Format("2006.01.02_15.04.05")) for i := 2; fileExists(filepath.Join(cfg.Folder, name)); i++ { name = fmt.Sprintf("playlink_backup_%s_%s_%d.zip", typ, now.Format("2006.01.02_15.04.05"), i) } snap := filepath.Join(cfg.Folder, ".snapshot-"+strconv.FormatInt(now.UnixNano(), 10)+".db") defer os.Remove(snap) if _, err := m.st.DB.ExecContext(ctx, `VACUUM INTO ?`, snap); err != nil { return Backup{}, fmt.Errorf("snapshot: %w", err) } var schema int _ = m.st.DB.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema) tmp := filepath.Join(cfg.Folder, "."+name+".tmp") if err := writeZip(tmp, snap, meta{App: "playlink", Version: m.version, Schema: schema, Type: typ, Reason: reason, Created: now.Unix()}); err != nil { _ = os.Remove(tmp) return Backup{}, err } final := filepath.Join(cfg.Folder, name) if err := os.Rename(tmp, final); err != nil { _ = os.Remove(tmp) return Backup{}, err } fi, _ := os.Stat(final) m.log.Info("backup created", "name", name, "reason", reason) m.prune(cfg) return Backup{Name: name, Type: typ, Size: fi.Size(), Created: now.Unix()}, nil } func writeZip(path, dbFile string, md meta) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) if err != nil { return err } zw := zip.NewWriter(f) fail := func(e error) error { zw.Close(); f.Close(); return e } w, err := zw.Create(dbName) if err != nil { return fail(err) } src, err := os.Open(dbFile) if err != nil { return fail(err) } _, err = io.Copy(w, src) src.Close() if err != nil { return fail(err) } w, err = zw.Create(metaName) if err != nil { return fail(err) } if err := json.NewEncoder(w).Encode(md); err != nil { return fail(err) } if err := zw.Close(); err != nil { f.Close() return err } if err := f.Sync(); err != nil { f.Close() return err } return f.Close() } func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } // List returns backups, newest first. func (m *Manager) List(ctx context.Context) ([]Backup, error) { cfg := m.Config(ctx) ents, err := os.ReadDir(cfg.Folder) if errors.Is(err, os.ErrNotExist) { return []Backup{}, nil } if err != nil { return nil, err } out := []Backup{} for _, en := range ents { sub := nameRe.FindStringSubmatch(en.Name()) if sub == nil || !en.Type().IsRegular() { continue } fi, err := en.Info() if err != nil { continue } out = append(out, Backup{Name: en.Name(), Type: sub[1], Size: fi.Size(), Created: createdOf(en.Name(), fi.ModTime())}) } sort.Slice(out, func(i, j int) bool { return out[i].Created > out[j].Created || (out[i].Created == out[j].Created && out[i].Name > out[j].Name) }) return out, nil } var stampRe = regexp.MustCompile(`\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}`) func createdOf(name string, fallback time.Time) int64 { if t, err := time.ParseInLocation("2006.01.02_15.04.05", stampRe.FindString(name), time.Local); err == nil { return t.Unix() } return fallback.Unix() } // Path returns the absolute path of a backup, refusing anything that is not // a backup file name (no path traversal). func (m *Manager) Path(ctx context.Context, name string) (string, error) { if !nameRe.MatchString(name) { return "", ErrBadName } p := filepath.Join(m.Config(ctx).Folder, name) if !fileExists(p) { return "", os.ErrNotExist } return p, nil } func (m *Manager) Delete(ctx context.Context, name string) error { p, err := m.Path(ctx, name) if err != nil { return err } return os.Remove(p) } // prune removes scheduled backups older than the retention period and all // but the newest action backups. Manual backups are kept until deleted. func (m *Manager) prune(cfg Config) { list, err := m.List(context.Background()) if err != nil { return } cutoff := time.Now().Add(-time.Duration(cfg.RetentionDays) * 24 * time.Hour).Unix() actions := 0 for _, b := range list { // newest first remove := false switch b.Type { case TypeScheduled: remove = b.Created < cutoff case TypeAction: actions++ remove = actions > keepActions } if remove { if err := os.Remove(filepath.Join(cfg.Folder, b.Name)); err == nil { m.log.Info("old backup removed", "name", b.Name) } } } } // Due makes a scheduled backup when the interval has passed. func (m *Manager) Due(ctx context.Context) { cfg := m.Config(ctx) if cfg.IntervalDays <= 0 { return } list, err := m.List(ctx) if err != nil { return } for _, b := range list { if b.Type == TypeScheduled { if time.Since(time.Unix(b.Created, 0)) < time.Duration(cfg.IntervalDays)*24*time.Hour { return } break } } if _, err := m.Create(ctx, TypeScheduled, "schedule"); err != nil { m.log.Warn("scheduled backup failed", "err", err) } } // BeforeAction makes an action backup when that option is on. An error means // the action must not run. func (m *Manager) BeforeAction(ctx context.Context, reason string) error { if !m.Config(ctx).BeforeActions { return nil } if _, err := m.Create(ctx, TypeAction, reason); err != nil { return fmt.Errorf("the backup before this action failed, so the action was not started: %w", err) } return nil } // ---- restore ---- // Stage validates a backup zip and places its database next to the live one, // to be swapped in at the next start. func (m *Manager) Stage(ctx context.Context, zipPath string, maxSchema int) error { m.mu.Lock() defer m.mu.Unlock() zr, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("not a valid backup zip: %w", err) } defer zr.Close() var dbf *zip.File for _, f := range zr.File { if f.Name == dbName { dbf = f } } if dbf == nil { return errors.New("the zip does not contain a Playlink database") } tmp := filepath.Join(m.dataDir, stagedName+".tmp") _ = os.Remove(tmp) defer os.Remove(tmp) if err := extract(dbf, tmp); err != nil { return err } if err := validate(ctx, tmp, maxSchema); err != nil { return err } if err := os.Rename(tmp, filepath.Join(m.dataDir, stagedName)); err != nil { return err } m.log.Warn("backup staged for restore; restarting to apply it", "from", filepath.Base(zipPath)) return nil } func extract(f *zip.File, dst string) error { rc, err := f.Open() if err != nil { return err } defer rc.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) if err != nil { return err } // Cap the size so a hostile zip cannot fill the disk. if _, err := io.Copy(out, io.LimitReader(rc, 20<<30)); err != nil { out.Close() return err } if err := out.Sync(); err != nil { out.Close() return err } return out.Close() } // validate opens a database file read-only and checks it is an intact // playlink database this version can use. func validate(ctx context.Context, path string, maxSchema int) error { db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") if err != nil { return err } defer db.Close() var ok string if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" { return fmt.Errorf("the backup database is damaged (%v %s)", err, ok) } var schema int if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil { return errors.New("the backup is not a Playlink database") } if schema < 1 || schema > maxSchema { return fmt.Errorf("the backup is from a newer version of Playlink (schema %d, this version supports up to %d)", schema, maxSchema) } var n int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 { return errors.New("the backup is not a Playlink database") } return nil } // ApplyStaged swaps a staged restore in before the database is opened. The // database it replaces is kept as playlink.db.pre-restore-