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>
114 lines
2.7 KiB
Go
114 lines
2.7 KiB
Go
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)
|
|
}
|