Add scheduled and manual backups

Backups are .tar.gz archives of a consistent database snapshot (VACUUM
INTO) plus the images folder. They run on a cron schedule, daily at 3:00
by default, and the newest N scheduled ones are kept (7 by default).
Manual backups are never pruned.

Settings → Backups offers schedule presets or a custom cron expression,
retention, "Back up now", and download/delete for each backup. A small
built-in parser handles standard 5-field cron syntax with names, ranges,
steps and @daily-style shorthands.

Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a
separate canon-backups volume at /backups, overridable with
CANON_BACKUP_PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 14:30:40 -07:00
1 parent 72b49d709a
commit f3185ba7bd
18 files changed
+940 -10

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
package server
import (
"errors"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/cdnutter/canon/internal/backup"
"github.com/cdnutter/canon/internal/store"
)
func (s *Server) apiBackups(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
s.writeErr(w, err)
return
}
list, err := s.backups.List()
if err != nil {
s.writeErr(w, err)
return
}
cron, keep := backup.Settings(set)
zone, _ := time.Now().Zone()
if tz := os.Getenv("TZ"); tz != "" && tz != zone {
zone = tz + ", " + zone
}
writeJSON(w, http.StatusOK, map[string]any{
"backups": list,
"status": s.backups.Status(),
"next": backup.NextRun(cron, time.Now()),
"cron": cron,
"keep": keep,
"dir": s.backups.Dir(),
"timezone": zone,
})
}
func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) {
info, err := s.backups.Create(r.Context(), true)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, info)
}
func (s *Server) apiBackupSchedule(w http.ResponseWriter, r *http.Request) {
var in struct {
Cron string `json:"cron"`
Keep int `json:"keep"`
}
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
cron := strings.Join(strings.Fields(in.Cron), " ")
if strings.EqualFold(cron, "off") || cron == "" {
cron = "off"
} else {
sched, err := backup.ParseCron(cron)
if err != nil {
s.writeErr(w, store.ValidationError{Msg: "Schedule: " + err.Error()})
return
}
if sched.Next(time.Now()).IsZero() {
s.writeErr(w, store.ValidationError{Msg: "That schedule never runs"})
return
}
}
if in.Keep < 1 || in.Keep > 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)
}