diff --git a/.env.example b/.env.example index 4840ba4..cc7c2b6 100644 --- a/.env.example +++ b/.env.example @@ -20,11 +20,17 @@ CANON_IGDB_CLIENT_SECRET= # CANON_HOST=0.0.0.0 # all network interfaces; 127.0.0.1 = this machine only # CANON_PORT=4242 # CANON_LOG_LEVEL=debug +# CANON_BACKUP_DIR=./data/backups # outside Docker; Compose sets /backups + +# Time zone for backup schedules (default UTC in Docker). +# TZ=America/Los_Angeles # ---- Docker Compose only (not read by Canon itself) ---- # Where the database and images live. Default: the canon_canon-data volume. # Use an absolute host path to keep them in a folder (writable by UID 1000). # CANON_DATA_PATH=/srv/canon +# Where backups are written. Default: the canon_canon-backups volume. +# CANON_BACKUP_PATH=/mnt/nas/canon-backups # Host address and port to publish on. # CANON_BIND=0.0.0.0 # CANON_PUBLISH_PORT=4242 diff --git a/Dockerfile b/Dockerfile index 041c4fd..3f86b75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" FROM alpine:3 RUN apk add --no-cache ca-certificates tzdata \ && adduser -D -u 1000 canon \ - && mkdir -p /data && chown canon:canon /data + && mkdir -p /data /backups && chown canon:canon /data /backups COPY --from=build /out/canon /usr/local/bin/canon USER canon ENV CANON_DATA_DIR=/data \ CANON_PORT=4242 -VOLUME /data +VOLUME /data /backups EXPOSE 4242 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD wget -qO- http://127.0.0.1:${CANON_PORT}/healthz >/dev/null || exit 1 diff --git a/README.md b/README.md index 7526250..f149247 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,21 @@ Canon was called TopDeck, and upgrading keeps all your data: ## Backups -Everything lives in the data volume: `canon.db` (SQLite) and `images/`. Copy the volume, or while running: +Canon backs itself up every day at 3:00 and keeps the last 7. Change this under **Settings → Backups**: pick a preset (hourly, every 6 hours, daily, weekly, monthly), write your own cron expression, or turn scheduled backups off. **Back up now** makes a manual backup; manual backups are never deleted automatically. Every backup can be downloaded or deleted from the same page. + +Each backup is a `.tar.gz` holding `canon.db` (a consistent snapshot, taken while Canon keeps running) and the `images/` folder. + +- **Where:** `CANON_BACKUP_DIR`, by default `data/backups`. Docker Compose mounts the `canon_canon-backups` volume at `/backups`; set `CANON_BACKUP_PATH` to a host folder (a NAS share, say) to keep them off the data volume. +- **Time zone:** schedules use the server's time zone, UTC in Docker unless you set `TZ` (e.g. `TZ=America/Los_Angeles`) in `.env`. +- **Cron syntax:** `minute hour day month weekday`, with `*`, lists (`1,15`), ranges (`mon-fri`), steps (`*/6`) and `@daily`-style shorthands. For example, `30 2 * * mon-fri` is 2:30 on weekdays. + +To restore, stop Canon, extract a backup into the data folder, and start it again: ```sh -docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-backup.tar +docker compose stop canon +docker run --rm -v canon_canon-data:/data -v canon_canon-backups:/backups alpine \ + sh -c 'rm -f /data/canon.db-wal /data/canon.db-shm && tar xzf /backups/.tar.gz -C /data && chown -R 1000:1000 /data' +docker compose start canon ``` ## Development diff --git a/cmd/canon/main.go b/cmd/canon/main.go index 3c56dcf..8144d67 100644 --- a/cmd/canon/main.go +++ b/cmd/canon/main.go @@ -18,6 +18,7 @@ import ( "time" "github.com/cdnutter/canon/internal/auth" + "github.com/cdnutter/canon/internal/backup" "github.com/cdnutter/canon/internal/config" "github.com/cdnutter/canon/internal/imgcache" "github.com/cdnutter/canon/internal/providers" @@ -58,7 +59,8 @@ func run(log *slog.Logger) error { if ver == "dev" { ver = fmt.Sprintf("dev-%d", time.Now().Unix()) } - srv, err := server.New(cfg, st, images, log, ver) + backups := backup.New(cfg.BackupDir, images.Dir(), st, log) + srv, err := server.New(cfg, st, images, backups, log, ver) if err != nil { return err } @@ -70,6 +72,8 @@ func run(log *slog.Logger) error { return err } + go backups.Run(ctx) + go func() { t := time.NewTicker(6 * time.Hour) defer t.Stop() diff --git a/docker-compose.yml b/docker-compose.yml index bb3df02..0e31e51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services: # A named volume by default, so a Git sync never touches the database. # Set CANON_DATA_PATH to a host folder (e.g. ./data) to keep it there. - ${CANON_DATA_PATH:-canon-data}:/data + # Backups get their own mount so they can live somewhere else, like a + # NAS share. Set CANON_BACKUP_PATH to a host folder to use one. + - ${CANON_BACKUP_PATH:-canon-backups}:/backups + environment: + CANON_BACKUP_DIR: /backups env_file: - path: .env required: false @@ -31,6 +36,7 @@ services: volumes: canon-data: + canon-backups: networks: chromanet: diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..f22d87e --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,337 @@ +// Package backup writes the database and cached images to .tar.gz archives, +// on demand and on a cron schedule. +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/cdnutter/canon/internal/store" +) + +const ( + DefaultCron = "0 3 * * *" // every day at 3:00 + DefaultKeep = 7 + MaxKeep = 365 +) + +var ErrNotFound = errors.New("backup not found") + +// Scheduled backups are named canon-YYYYMMDD-HHMMSS.tar.gz; ones made with +// "Back up now" end in -manual and are never pruned automatically. +var nameRe = regexp.MustCompile(`^canon-\d{8}-\d{6}(-\d+)?(-manual)?\.tar\.gz$`) + +type Info struct { + Name string `json:"name"` + Size int64 `json:"size"` + Created time.Time `json:"created"` + Manual bool `json:"manual"` +} + +type Status struct { + LastRun time.Time `json:"lastRun,omitzero"` + LastErr string `json:"lastError,omitempty"` +} + +type Manager struct { + dir string + imagesDir string + store *store.Store + log *slog.Logger + + run sync.Mutex // one backup at a time + reload chan struct{} + + mu sync.Mutex + status Status +} + +func New(dir, imagesDir string, st *store.Store, log *slog.Logger) *Manager { + return &Manager{dir: dir, imagesDir: imagesDir, store: st, log: log, reload: make(chan struct{}, 1)} +} + +func (m *Manager) Dir() string { return m.dir } + +func (m *Manager) Status() Status { + m.mu.Lock() + defer m.mu.Unlock() + return m.status +} + +// Reschedule makes the scheduler re-read its settings. +func (m *Manager) Reschedule() { + select { + case m.reload <- struct{}{}: + default: + } +} + +// Settings returns the configured schedule and retention, with defaults. +func Settings(set map[string]string) (cron string, keep int) { + cron = strings.TrimSpace(set[store.SettingBackupCron]) + if cron == "" { + cron = DefaultCron + } + keep = DefaultKeep + if n, err := strconv.Atoi(set[store.SettingBackupKeep]); err == nil && n > 0 { + keep = n + } + return cron, keep +} + +// NextRun is when the schedule next fires after t, or nil when backups are +// off or the schedule is invalid. +func NextRun(cron string, t time.Time) *time.Time { + if cron == "off" { + return nil + } + sched, err := ParseCron(cron) + if err != nil { + return nil + } + next := sched.Next(t) + if next.IsZero() { + return nil + } + return &next +} + +// Run backs up on schedule until ctx is canceled. +func (m *Manager) Run(ctx context.Context) { + for { + set, err := m.store.Settings(ctx) + if err != nil { + m.log.Warn("backup: loading settings", "err", err) + } + cron, keep := Settings(set) + var next time.Time + if n := NextRun(cron, time.Now()); n != nil { + next = *n + } else if cron != "off" { + m.log.Warn("backup: invalid schedule, not backing up", "cron", cron) + } + + // Wake at least hourly so a changed clock or a suspended host + // doesn't delay a backup by long. + wait := time.Hour + if !next.IsZero() { + wait = min(wait, time.Until(next)) + } + timer := time.NewTimer(max(wait, 0)) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-m.reload: + timer.Stop() + continue + case <-timer.C: + } + if next.IsZero() || time.Now().Before(next) { + continue + } + if _, err := m.Create(ctx, false); err != nil { + m.log.Error("scheduled backup failed", "err", err) + continue + } + if err := m.prune(keep); err != nil { + m.log.Warn("backup: removing old backups", "err", err) + } + } +} + +// Create writes a new backup and returns it. +func (m *Manager) Create(ctx context.Context, manual bool) (info Info, err error) { + m.run.Lock() + defer m.run.Unlock() + defer func() { + m.mu.Lock() + m.status.LastRun = time.Now() + m.status.LastErr = "" + if err != nil { + m.status.LastErr = err.Error() + } + m.mu.Unlock() + }() + + if err := os.MkdirAll(m.dir, 0o755); err != nil { + return info, err + } + name := m.freeName(time.Now(), manual) + tmpDir, err := os.MkdirTemp(m.dir, ".tmp-") + if err != nil { + return info, err + } + defer os.RemoveAll(tmpDir) + + dbCopy := filepath.Join(tmpDir, "canon.db") + if err := m.store.SnapshotTo(ctx, dbCopy); err != nil { + return info, fmt.Errorf("copying database: %w", err) + } + partial := filepath.Join(tmpDir, name) + if err := m.writeArchive(partial, dbCopy); err != nil { + return info, err + } + final := filepath.Join(m.dir, name) + if err := os.Rename(partial, final); err != nil { + return info, err + } + fi, err := os.Stat(final) + if err != nil { + return info, err + } + info = Info{Name: name, Size: fi.Size(), Created: fi.ModTime(), Manual: manual} + m.log.Info("backup written", "file", final, "size", fi.Size(), "manual", manual) + return info, nil +} + +func (m *Manager) freeName(t time.Time, manual bool) string { + base := "canon-" + t.Format("20060102-150405") + suffix := ".tar.gz" + if manual { + suffix = "-manual.tar.gz" + } + name := base + suffix + for i := 2; ; i++ { + if _, err := os.Stat(filepath.Join(m.dir, name)); errors.Is(err, os.ErrNotExist) { + return name + } + name = fmt.Sprintf("%s-%d%s", base, i, suffix) + } +} + +func (m *Manager) writeArchive(path, dbCopy string) (err error) { + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + if cerr := f.Close(); err == nil { + err = cerr + } + }() + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + if err := addFile(tw, dbCopy, "canon.db"); err != nil { + return err + } + entries, err := os.ReadDir(m.imagesDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for _, e := range entries { + if !e.Type().IsRegular() || strings.HasPrefix(e.Name(), ".") { + continue // skip in-progress uploads + } + if err := addFile(tw, filepath.Join(m.imagesDir, e.Name()), "images/"+e.Name()); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue // removed while we were working + } + return err + } + } + if err := tw.Close(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + return f.Sync() +} + +func addFile(tw *tar.Writer, src, name string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return err + } + hdr := &tar.Header{Name: name, Mode: 0o644, Size: fi.Size(), ModTime: fi.ModTime(), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err = io.CopyN(tw, f, fi.Size()) + return err +} + +// List returns backups, newest first. +func (m *Manager) List() ([]Info, error) { + entries, err := os.ReadDir(m.dir) + if errors.Is(err, os.ErrNotExist) { + return []Info{}, nil + } + if err != nil { + return nil, err + } + out := []Info{} + for _, e := range entries { + if !e.Type().IsRegular() || !nameRe.MatchString(e.Name()) { + continue + } + fi, err := e.Info() + if err != nil { + continue + } + out = append(out, Info{Name: e.Name(), Size: fi.Size(), Created: fi.ModTime(), Manual: strings.HasSuffix(e.Name(), "-manual.tar.gz")}) + } + slices.SortFunc(out, func(a, b Info) int { return b.Created.Compare(a.Created) }) + return out, nil +} + +// Path returns the file for a backup name, refusing anything that isn't one. +func (m *Manager) Path(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", ErrNotFound + } + p := filepath.Join(m.dir, name) + if _, err := os.Stat(p); err != nil { + return "", ErrNotFound + } + return p, nil +} + +func (m *Manager) Delete(name string) error { + p, err := m.Path(name) + if err != nil { + return err + } + return os.Remove(p) +} + +// prune keeps the newest keep scheduled backups. Manual ones stay until +// deleted by hand. +func (m *Manager) prune(keep int) error { + all, err := m.List() + if err != nil { + return err + } + n := 0 + for _, b := range all { + if b.Manual { + continue + } + if n++; n > keep { + if err := os.Remove(filepath.Join(m.dir, b.Name)); err != nil { + return err + } + m.log.Info("removed old backup", "file", b.Name) + } + } + return nil +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..8e4f829 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,89 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "io" + "log/slog" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/cdnutter/canon/internal/store" +) + +func TestCreateListPrune(t *testing.T) { + data := t.TempDir() + st, err := store.Open(data) + if err != nil { + t.Fatal(err) + } + defer st.Close() + images := filepath.Join(data, "images") + os.MkdirAll(images, 0o755) + os.WriteFile(filepath.Join(images, "abc.jpg"), []byte("jpeg"), 0o644) + os.WriteFile(filepath.Join(images, ".upload-123"), []byte("partial"), 0o644) + + m := New(filepath.Join(data, "backups"), images, st, slog.New(slog.DiscardHandler)) + ctx := context.Background() + manual, err := m.Create(ctx, true) + if err != nil { + t.Fatal(err) + } + if got := archiveNames(t, filepath.Join(m.Dir(), manual.Name)); !slices.Equal(got, []string{"canon.db", "images/abc.jpg"}) { + t.Fatalf("archive holds %v", got) + } + for range 3 { + if _, err := m.Create(ctx, false); err != nil { + t.Fatal(err) + } + } + if err := m.prune(2); err != nil { + t.Fatal(err) + } + list, _ := m.List() + var scheduled, manuals int + for _, b := range list { + if b.Manual { + manuals++ + } else { + scheduled++ + } + } + if scheduled != 2 || manuals != 1 { + t.Fatalf("after prune: %d scheduled, %d manual", scheduled, manuals) + } + if _, err := m.Path("../canon.db"); err == nil { + t.Fatal("path traversal accepted") + } + if err := m.Delete(manual.Name); err != nil { + t.Fatal(err) + } +} + +func archiveNames(t *testing.T, path string) []string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(gz) + var names []string + for { + h, err := tr.Next() + if err == io.EOF { + return names + } + if err != nil { + t.Fatal(err) + } + names = append(names, h.Name) + } +} diff --git a/internal/backup/cron.go b/internal/backup/cron.go new file mode 100644 index 0000000..7d9370c --- /dev/null +++ b/internal/backup/cron.go @@ -0,0 +1,162 @@ +package backup + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Schedule is a parsed five-field cron expression: +// minute hour day-of-month month day-of-week. +type Schedule struct { + minute, hour, dom, month, dow uint64 // bit n set = value n allowed + domAny, dowAny bool +} + +var shorthands = map[string]string{ + "@hourly": "0 * * * *", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@weekly": "0 0 * * 0", + "@monthly": "0 0 1 * *", + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", +} + +var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12} +var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6} + +// ParseCron parses standard cron syntax: *, lists (1,15), ranges (1-5), +// steps (*/15, 0-30/10), month and weekday names, and the @daily-style +// shorthands. Sunday is 0 or 7. +func ParseCron(expr string) (Schedule, error) { + expr = strings.TrimSpace(strings.ToLower(expr)) + if s, ok := shorthands[expr]; ok { + expr = s + } + f := strings.Fields(expr) + if len(f) != 5 { + return Schedule{}, fmt.Errorf("a cron schedule has 5 fields (minute hour day month weekday), got %d", len(f)) + } + var s Schedule + var err error + if s.minute, err = parseField(f[0], 0, 59, nil); err != nil { + return s, fmt.Errorf("minute: %w", err) + } + if s.hour, err = parseField(f[1], 0, 23, nil); err != nil { + return s, fmt.Errorf("hour: %w", err) + } + if s.dom, err = parseField(f[2], 1, 31, nil); err != nil { + return s, fmt.Errorf("day of month: %w", err) + } + if s.month, err = parseField(f[3], 1, 12, monthNames); err != nil { + return s, fmt.Errorf("month: %w", err) + } + if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil { + return s, fmt.Errorf("weekday: %w", err) + } + if s.dow&(1<<7) != 0 { + s.dow |= 1 // 7 is Sunday too + } + s.domAny = f[2] == "*" || strings.HasPrefix(f[2], "*/") + s.dowAny = f[4] == "*" || strings.HasPrefix(f[4], "*/") + return s, nil +} + +func parseField(field string, min, max int, names map[string]int) (uint64, error) { + var bits uint64 + for _, part := range strings.Split(field, ",") { + rng, stepStr, hasStep := strings.Cut(part, "/") + step := 1 + if hasStep { + n, err := strconv.Atoi(stepStr) + if err != nil || n < 1 { + return 0, fmt.Errorf("bad step %q", stepStr) + } + step = n + } + lo, hi := min, max + if rng != "*" { + a, b, isRange := strings.Cut(rng, "-") + var err error + if lo, err = parseValue(a, min, max, names); err != nil { + return 0, err + } + hi = lo + if isRange { + if hi, err = parseValue(b, min, max, names); err != nil { + return 0, err + } + if hi < lo { + return 0, fmt.Errorf("range %q runs backwards", rng) + } + } else if hasStep { + hi = max // "5/10" means from 5 to the end, every 10 + } + } + for v := lo; v <= hi; v += step { + bits |= 1 << uint(v) + } + } + return bits, nil +} + +func parseValue(s string, min, max int, names map[string]int) (int, error) { + if n, ok := names[s]; ok { + return n, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("%q isn't a number", s) + } + if n < min || n > max { + return 0, fmt.Errorf("%d is outside %d-%d", n, min, max) + } + return n, nil +} + +// Next returns the first matching minute strictly after t, in t's +// location. It returns the zero time if nothing matches within 5 years +// (e.g. "0 0 30 2 *"). +func (s Schedule) Next(t time.Time) time.Time { + t = t.Truncate(time.Minute).Add(time.Minute) + limit := t.AddDate(5, 0, 0) + for t.Before(limit) { + if s.month&(1< backup.MaxKeep { + s.writeErr(w, store.ValidationError{Msg: "Keep between 1 and " + strconv.Itoa(backup.MaxKeep) + " backups"}) + return + } + if err := s.store.SetSettings(r.Context(), map[string]string{ + store.SettingBackupCron: cron, + store.SettingBackupKeep: strconv.Itoa(in.Keep), + }); err != nil { + s.writeErr(w, err) + return + } + s.backups.Reschedule() + s.apiBackups(w, r) +} + +func (s *Server) apiDeleteBackup(w http.ResponseWriter, r *http.Request) { + err := s.backups.Delete(r.PathValue("name")) + if errors.Is(err, backup.ErrNotFound) { + writeError(w, http.StatusNotFound, "Not found") + return + } + if err != nil { + s.writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleBackupDownload(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path, err := s.backups.Path(name) + if err != nil { + s.notFound(w, r) + return + } + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, path) +} diff --git a/internal/server/server.go b/internal/server/server.go index 318a363..b5e6c83 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,6 +19,7 @@ import ( "time" "github.com/cdnutter/canon/internal/auth" + "github.com/cdnutter/canon/internal/backup" "github.com/cdnutter/canon/internal/config" "github.com/cdnutter/canon/internal/imgcache" "github.com/cdnutter/canon/internal/providers" @@ -35,6 +36,7 @@ type Server struct { cfg config.Config store *store.Store images *imgcache.Cache + backups *backup.Manager providers *providers.Registry log *slog.Logger @@ -48,11 +50,12 @@ type Server struct { previewClient *http.Client } -func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.Logger, version string) (*Server, error) { +func New(cfg config.Config, st *store.Store, images *imgcache.Cache, backups *backup.Manager, log *slog.Logger, version string) (*Server, error) { s := &Server{ cfg: cfg, store: st, images: images, + backups: backups, log: log, assetVer: version, loginLimit: auth.NewLimiter(10, 15*time.Minute), @@ -125,6 +128,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList)) mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings)) mux.HandleFunc("GET /admin/preview", s.requireUser(s.handleAdminPreview)) + mux.HandleFunc("GET /admin/backups/{name}", s.requireUser(s.handleBackupDownload)) // Admin JSON API api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) } @@ -147,6 +151,10 @@ func (s *Server) Handler() http.Handler { api("DELETE /api/settings/avatar", s.apiDeleteAvatar) api("POST /api/integrations/test", s.apiTestIntegration) api("PUT /api/account", s.apiUpdateAccount) + api("GET /api/backups", s.apiBackups) + api("POST /api/backups", s.apiCreateBackup) + api("PUT /api/backups/schedule", s.apiBackupSchedule) + api("DELETE /api/backups/{name}", s.apiDeleteBackup) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) }) diff --git a/internal/store/settings.go b/internal/store/settings.go index 1f26fbb..4939497 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -20,6 +20,8 @@ const ( SettingShowPreviews = "show_previews" SettingMusicProvider = "music_provider" SettingTVProvider = "tv_provider" + SettingBackupCron = "backup_cron" // cron expression, or "off" + SettingBackupKeep = "backup_keep" // how many scheduled backups to keep SettingTMDBKey = "tmdb_api_key" SettingTVDBKey = "tvdb_api_key" diff --git a/internal/store/store.go b/internal/store/store.go index 7aff240..e571680 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -143,3 +143,10 @@ func (s *Store) migrate(ctx context.Context) error { } return nil } + +// SnapshotTo writes a consistent copy of the database to path, which must +// not exist yet. It's safe to call while the app is serving requests. +func (s *Store) SnapshotTo(ctx context.Context, path string) error { + _, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path) + return err +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 1192253..87ccaff 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -777,3 +777,11 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } + +/* ---------- settings: backups ---------- */ +.backup-status { font-size: 14px; } +.backup-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); } +.backup-list li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } +.backup-list .backup-when { flex: 1; min-width: 0; font-size: 14px; font-weight: 550; } +.backup-list .backup-meta { display: block; color: var(--muted); font-size: 12.5px; font-weight: 400; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.backup-list .backup-actions { display: flex; gap: 2px; flex: none; } diff --git a/web/static/img/icons.svg b/web/static/img/icons.svg index 009eaec..46c9a33 100644 --- a/web/static/img/icons.svg +++ b/web/static/img/icons.svg @@ -43,5 +43,6 @@ + diff --git a/web/static/js/admin-settings.js b/web/static/js/admin-settings.js index fa9e653..6f987d0 100644 --- a/web/static/js/admin-settings.js +++ b/web/static/js/admin-settings.js @@ -1,5 +1,5 @@ -// Settings: profile, appearance, integrations, account. -import { h, icon, api, toast, busy } from "./common.js"; +// Settings: profile, appearance, integrations, backups, account. +import { h, icon, api, toast, busy, confirmDialog } from "./common.js"; const root = document.getElementById("settings"); let settings; @@ -318,6 +318,127 @@ function integrationsSection() { ); } +// ---------- backups ---------- + +const SCHEDULES = [ + ["off", "Off"], + ["0 * * * *", "Every hour"], + ["0 */6 * * *", "Every 6 hours"], + ["0 3 * * *", "Every day at 3:00"], + ["0 3 * * 0", "Every Sunday at 3:00"], + ["0 3 1 * *", "The 1st of each month at 3:00"], +]; + +const when = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); + +function formatSize(bytes) { + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } + return `${bytes.toFixed(i && bytes < 10 ? 1 : 0)} ${units[i]}`; +} + +function backupsSection() { + let data; + const preset = h("select", {}, SCHEDULES.map(([v, label]) => h("option", { value: v }, label)), h("option", { value: "custom" }, "Custom (cron)…")); + const cron = h("input", { spellcheck: false, placeholder: "30 2 * * mon-fri", style: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } }); + const cronField = field("Cron expression", cron); + const keep = h("input", { type: "number", min: 1, max: 365, style: { maxWidth: "120px" } }); + const status = h("p", { class: "backup-status", "aria-live": "polite" }); + const list = h("ul", { class: "backup-list" }); + const empty = h("p", { class: "muted" }, "No backups yet."); + const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save schedule"); + const now = h("button", { type: "button", class: "btn" }, icon("archive"), "Back up now"); + + const showCustom = () => { cronField.hidden = preset.value !== "custom"; }; + preset.addEventListener("change", () => { + if (preset.value === "custom" && !cron.value) cron.value = data.cron === "off" ? "0 3 * * *" : data.cron; + showCustom(); + }); + + const render = (d) => { + data = d; + const known = SCHEDULES.some(([v]) => v === d.cron); + preset.value = known ? d.cron : "custom"; + cron.value = known ? "" : d.cron; + keep.value = d.keep; + showCustom(); + const parts = [d.next ? `Next backup: ${when(d.next)}.` : "Scheduled backups are off."]; + if (d.status.lastRun) parts.push(d.status.lastError ? `The last backup failed: ${d.status.lastError}` : `Last backup: ${when(d.status.lastRun)}.`); + status.textContent = parts.join(" "); + status.className = "backup-status" + (d.status.lastError ? " alert alert--error" : ""); + list.replaceChildren(...d.backups.map(backupRow)); + empty.hidden = d.backups.length > 0; + }; + const reload = async () => render(await api("GET", "/api/backups")); + + const backupRow = (b) => h("li", {}, + h("span", { class: "backup-when" }, when(b.created), " ", b.manual ? h("span", { class: "badge badge--muted" }, "Manual") : null, + h("span", { class: "backup-meta" }, `${formatSize(b.size)} · ${b.name}`)), + h("span", { class: "backup-actions" }, + h("a", { class: "icon-btn", href: `/admin/backups/${encodeURIComponent(b.name)}`, download: b.name, title: "Download", "aria-label": `Download ${b.name}` }, icon("download")), + h("button", { + type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": `Delete ${b.name}`, + onclick: async () => { + if (!await confirmDialog({ title: "Delete this backup?", message: `${b.name} will be removed from the server.`, confirm: "Delete", danger: true })) return; + try { + await api("DELETE", `/api/backups/${encodeURIComponent(b.name)}`); + toast("Backup deleted"); + await reload(); + } catch (err) { toast(err.message, { error: true }); } + }, + }, icon("trash")), + ), + ); + + now.addEventListener("click", async () => { + busy(now, true); + try { + await api("POST", "/api/backups"); + toast("Backup created"); + } catch (err) { + toast(err.message, { error: true }); + } finally { + busy(now, false); + reload().catch(() => {}); + } + }); + + const form = h("form", { class: "stack" }, + h("div", { class: "row" }, + field("Schedule", preset), + field("Scheduled backups to keep", keep, "Older ones are deleted. Manual backups are kept until you delete them."), + ), + cronField, + status, + h("div", { class: "settings-actions" }, now, save), + ); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + busy(save, true); + try { + render(await api("PUT", "/api/backups/schedule", { + cron: preset.value === "custom" ? cron.value : preset.value, + keep: Number(keep.value), + })); + toast("Backup schedule saved"); + } catch (err) { + toast(err.message, { error: true }); + } finally { + busy(save, false); + } + }); + + const hint = h("span", { class: "field-hint" }); + const el = section("backups", "Backups", + "Each backup is a .tar.gz holding your database and every image, so it's all you need to restore.", + form, h("div", { class: "field" }, h("span", { class: "field-label" }, "Saved backups"), list, empty, hint)); + reload().then(() => { + hint.textContent = `Stored in ${data.dir} on the server. Schedules use the server's time zone, ${data.timezone}.`; + }).catch((err) => status.replaceChildren(err.message)); + return el; +} + // ---------- account ---------- function accountSection() { @@ -358,7 +479,7 @@ async function init() { root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message)); return; } - root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), accountSection()); + root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection()); root.removeAttribute("aria-busy"); if (location.hash) document.querySelector(location.hash)?.scrollIntoView(); } diff --git a/web/templates/pages/admin_settings.html b/web/templates/pages/admin_settings.html index 84dc57d..526ef7a 100644 --- a/web/templates/pages/admin_settings.html +++ b/web/templates/pages/admin_settings.html @@ -12,6 +12,7 @@ {{icon "user"}}Profile {{icon "palette"}}Appearance {{icon "plug"}}Integrations + {{icon "archive"}}Backups {{icon "lock"}}Account