diff --git a/README.md b/README.md index d401082..5982e10 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ tags** buttons. Edits are safe with hardlinks: **Organize (rename files from tags).** The *Organize* tab sets a naming layout, by default `{artist}/{album}/{track} {title}` → `Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to the artist). Tokens: `{artist}`, `{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get a disc prefix (`2-04 Title`) or -a `Disc 2` folder, and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`). +a `Disc 02` folder (two-digit disc numbers by default, optionally `Disc 2`), and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`). With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover @@ -94,6 +94,14 @@ art follows a whole folder, and emptied folders are removed. Files with missing in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file at a different path is still recognised as linked. +**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and +history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 +or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be +downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version +are refused), then playlink restarts to swap the database in and keeps the replaced one as `playlink.db.pre-restore-…`; Docker's restart +policy brings it back automatically. Optionally, **back up before every action in live mode**: one backup per action you start (and +before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept. + **Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan. Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go index 2a841d7..ff12b19 100644 --- a/cmd/playlink/main.go +++ b/cmd/playlink/main.go @@ -15,6 +15,7 @@ import ( "time" "github.com/cdnutter/playlink/internal/api" + "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" @@ -82,6 +83,11 @@ func main() { log.Error("create data dir", "err", err) os.Exit(1) } + // A restore chosen in the UI is swapped in before the database opens. + if err := backup.ApplyStaged(dataDir, log); err != nil { + log.Error("apply restored backup", "err", err) + os.Exit(1) + } st, err := store.Open(dataDir) if err != nil { log.Error("open database", "err", err) @@ -99,12 +105,19 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + backups := backup.New(st, dataDir, version, log) + eng.SetBeforeAction(backups.BeforeAction) + // Restoring a backup restarts the process (Docker's restart policy + // brings it back) so the database is swapped while nothing uses it. + ctx, restart := context.WithCancel(ctx) + defer restart() + eng.Recover(ctx) - go scheduler(ctx, st, eng, log) + go scheduler(ctx, st, eng, backups, log) srv := &http.Server{ Addr: addr, - Handler: api.New(api.Config{SecureCookies: secure, Version: version}, st, eng, log, web.Dist()).Handler(), + Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Restart: restart}, st, eng, log, web.Dist()).Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, @@ -125,12 +138,13 @@ func main() { } // scheduler starts interval scans and hourly maintenance. -func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, log *slog.Logger) { +func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, backups *backup.Manager, log *slog.Logger) { tick := time.NewTicker(time.Minute) defer tick.Stop() lastMaint := time.Time{} for { if time.Since(lastMaint) >= time.Hour { + backups.Due(ctx) eng.Maintenance(ctx) lastMaint = time.Now() } diff --git a/internal/api/backups.go b/internal/api/backups.go new file mode 100644 index 0000000..120c6b8 --- /dev/null +++ b/internal/api/backups.go @@ -0,0 +1,152 @@ +package api + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/cdnutter/playlink/internal/backup" + "github.com/cdnutter/playlink/internal/store" +) + +func (s *Server) backupRoutes(r chi.Router) { + r.Get("/backups", s.handleListBackups) + r.Put("/backups/config", s.handleBackupConfig) + r.Post("/backups", s.handleCreateBackup) + r.Post("/backups/restore-upload", s.handleRestoreUpload) + r.Get("/backups/{name}/download", s.handleDownloadBackup) + r.Post("/backups/{name}/restore", s.handleRestoreBackup) + r.Delete("/backups/{name}", s.handleDeleteBackup) +} + +func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) { + list, err := s.cfg.Backups.List(r.Context()) + if err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, map[string]any{"config": s.cfg.Backups.Config(r.Context()), "backups": list}) +} + +func (s *Server) handleBackupConfig(w http.ResponseWriter, r *http.Request) { + var c backup.Config + if !readJSON(w, r, &c) { + return + } + if err := s.cfg.Backups.SetConfig(r.Context(), c); err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 200, s.cfg.Backups.Config(r.Context())) +} + +func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { + b, err := s.cfg.Backups.Create(r.Context(), backup.TypeManual, "manual") + if err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, b) +} + +func (s *Server) backupPath(w http.ResponseWriter, r *http.Request) (string, bool) { + p, err := s.cfg.Backups.Path(r.Context(), chi.URLParam(r, "name")) + switch { + case errors.Is(err, backup.ErrBadName): + writeErr(w, 400, err) + case err != nil: + writeErr(w, 404, errors.New("backup not found")) + default: + return p, true + } + return "", false +} + +func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { + p, ok := s.backupPath(w, r) + if !ok { + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(p))) + http.ServeFile(w, r, p) +} + +func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) { + if _, ok := s.backupPath(w, r); !ok { + return + } + if err := s.cfg.Backups.Delete(r.Context(), chi.URLParam(r, "name")); err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// restoreBusy refuses a restore while any job runs: the database is about to +// be replaced. +func (s *Server) restoreBusy(w http.ResponseWriter, r *http.Request) bool { + pairs, _ := s.st.Pairs(r.Context()) + for _, p := range pairs { + if j := s.eng.Job(p.ID); j != nil && j.State == "running" { + writeErr(w, 409, fmt.Errorf("a job is running for %q; wait for it to finish before restoring", p.Name)) + return true + } + } + return false +} + +func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { + p, ok := s.backupPath(w, r) + if !ok || s.restoreBusy(w, r) { + return + } + s.stageAndRestart(w, r, p) +} + +func (s *Server) handleRestoreUpload(w http.ResponseWriter, r *http.Request) { + if s.restoreBusy(w, r) { + return + } + file, _, err := r.FormFile("backup") + if err != nil { + writeErr(w, 400, errors.New("choose a backup .zip file to upload")) + return + } + defer file.Close() + tmp, err := os.CreateTemp(s.cfg.Backups.Config(r.Context()).Folder, ".upload-*.zip") + if err != nil { + writeErr(w, 500, err) + return + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, file); err != nil { + tmp.Close() + writeErr(w, 400, err) + return + } + tmp.Close() + s.stageAndRestart(w, r, tmp.Name()) +} + +func (s *Server) stageAndRestart(w http.ResponseWriter, r *http.Request, zipPath string) { + if err := s.cfg.Backups.Stage(r.Context(), zipPath, store.SchemaVersion()); err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 202, map[string]any{"ok": true, "restarting": true}) + if s.cfg.Restart != nil { + // Let the response reach the browser before shutting down. + go func() { + time.Sleep(500 * time.Millisecond) + s.log.Warn("restarting to apply the restored backup") + s.cfg.Restart() + }() + } +} diff --git a/internal/api/server.go b/internal/api/server.go index ba93dd3..a1ab149 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/store" @@ -28,6 +29,8 @@ import ( type Config struct { SecureCookies bool Version string + Backups *backup.Manager + Restart func() // stops the server so the process restarts (after a restore) } type Server struct { @@ -54,7 +57,11 @@ func (s *Server) Handler() http.Handler { r.Use(s.csrf) r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + limit := int64(1 << 20) + if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") { + limit = 20 << 30 // backup zips can be large + } + r.Body = http.MaxBytesReader(w, r.Body, limit) w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) @@ -72,6 +79,9 @@ func (s *Server) Handler() http.Handler { r.Get("/settings", s.handleGetSettings) r.Put("/settings", s.handlePutSettings) r.Get("/browse", s.handleBrowse) + if s.cfg.Backups != nil { + s.backupRoutes(r) + } r.Get("/pairs", s.handleListPairs) r.Post("/pairs", s.handleSavePair) @@ -340,6 +350,7 @@ type pairInput struct { OrganizeTemplate string `json:"organize_template"` OrganizeMultiDisc string `json:"organize_multidisc"` AutoOrganize string `json:"auto_organize"` + OrganizeDiscDigits int `json:"organize_disc_digits"` } func (in pairInput) apply(p *store.Pair) { @@ -348,6 +359,7 @@ func (in pairInput) apply(p *store.Pair) { p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize + p.OrganizeDiscDigits = in.OrganizeDiscDigits } func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) { @@ -469,6 +481,9 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) + if !s.preAction(w, r, id, "link identical") { + return + } j, err := s.eng.StartLinkIdentical(id) if err != nil { opErr(w, err) @@ -492,14 +507,14 @@ type pathsBody struct { } func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) { - s.pathsJob(w, r, s.eng.StartBuild) + s.pathsJob(w, r, "build", s.eng.StartBuild) } func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) { - s.pathsJob(w, r, s.eng.StartRemove) + s.pathsJob(w, r, "remove from slave", s.eng.StartRemove) } -func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int64, []string) (*engine.Job, error)) { +func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, reason string, start func(int64, []string) (*engine.Job, error)) { id, _ := idParam(r) var body pathsBody if !readJSON(w, r, &body) { @@ -515,6 +530,9 @@ func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int return } } + if !s.preAction(w, r, id, reason) { + return + } j, err := start(id, body.Paths) if err != nil { opErr(w, err) @@ -534,6 +552,9 @@ func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } + if !s.preAction(w, r, 0, "link") { + return + } op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade, KeepFolder: body.KeepFolder}) if err != nil { opErr(w, err) @@ -550,6 +571,9 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } + if body.Confirm && !s.preAction(w, r, 0, "promote") { + return + } op, err := s.eng.Promote(r.Context(), id, body.Confirm) if err != nil { opErr(w, err) @@ -590,6 +614,9 @@ func (s *Server) handleOps(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) + if !s.preAction(w, r, 0, "undo") { + return + } if err := s.eng.Undo(r.Context(), id); err != nil { opErr(w, err) return @@ -599,6 +626,9 @@ func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) + if !s.preAction(w, r, 0, "purge quarantine") { + return + } if err := s.eng.Purge(r.Context(), id); err != nil { opErr(w, err) return @@ -653,6 +683,9 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]any{"changed": n}) default: + if !s.preAction(w, r, id, "bulk "+b.Action) { + return + } j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions) if err != nil { opErr(w, err) @@ -690,6 +723,9 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) { return } } + if !s.preAction(w, r, id, "rename folders") { + return + } j, err := s.eng.StartRenames(id, body.Only) if err != nil { opErr(w, err) @@ -735,6 +771,9 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } + if !s.preAction(w, r, id, "edit tags") { + return + } j, err := s.eng.StartTagEdit(id, body) if err != nil { opErr(w, err) @@ -743,3 +782,21 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) { s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate) writeJSON(w, 202, j) } + +// preAction runs the optional database backup before an action that changes +// files (live mode only). It refuses up front when the pair is busy, so no +// backup is made for an action that could not start. +func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, reason string) bool { + if pairID != 0 { + if j := s.eng.Job(pairID); j != nil && j.State == "running" { + writeErr(w, 409, engine.ErrBusy) + return false + } + } + if err := s.eng.BeforeAction(r.Context(), reason); err != nil { + s.log.Warn("pre-action backup failed", "action", reason, "err", err) + writeErr(w, 500, err) + return false + } + return true +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..1da3674 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,434 @@ +// 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-