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:
1 parent
72b49d709a
commit
f3185ba7bd
18 files changed
+940
-10
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
|
||||
Reference in new issue
Block a user