Files
playlink/internal/backup/backup.go
T
cdnutterandClaude Opus 5.5 c98eb2f41d Rebrand as Playlink with a new sunset theme
- Name: Playlink in the UI, page title, footer, messages and README
  (file names like playlink.db and .playlink-trash are unchanged).
- Theme: plum-black surfaces with a coral-to-pink accent, a soft glow
  behind the page, gradient primary buttons, toggles and progress bars,
  accent focus rings and slim scrollbars.
- New logo (beamed notes on the brand gradient) and a Play/link wordmark.
- Inter variable font, bundled so it works under the strict CSP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:49:21 -07:00

435 lines
12 KiB
Go

// 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-<time>.
func ApplyStaged(dataDir string, log *slog.Logger) error {
staged := filepath.Join(dataDir, stagedName)
if !fileExists(staged) {
return nil
}
live := filepath.Join(dataDir, dbName)
keep := live + ".pre-restore-" + time.Now().Format("2006.01.02_15.04.05")
if fileExists(live) {
if err := os.Rename(live, keep); err != nil {
return err
}
for _, sfx := range []string{"-wal", "-shm"} {
if fileExists(live + sfx) {
_ = os.Rename(live+sfx, keep+sfx)
}
}
}
if err := os.Rename(staged, live); err != nil {
return err
}
log.Warn("database restored from backup", "previous database kept as", filepath.Base(keep))
return nil
}