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

+6
View File
@@ -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
+2 -2
View File
@@ -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
+13 -2
View File
@@ -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/<file>.tar.gz -C /data && chown -R 1000:1000 /data'
docker compose start canon
```
## Development
+5 -1
View File
@@ -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()
+6
View File
@@ -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:
+337
View File
@@ -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
}
+89
View File
@@ -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)
}
}
+162
View File
@@ -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<<uint(t.Month())) == 0 {
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, t.Location())
continue
}
if !s.dayMatches(t) {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
continue
}
if s.hour&(1<<uint(t.Hour())) == 0 {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
continue
}
if s.minute&(1<<uint(t.Minute())) == 0 {
t = t.Add(time.Minute)
continue
}
return t
}
return time.Time{}
}
// dayMatches follows cron's rule: when both day fields are restricted, a
// day matching either one counts.
func (s Schedule) dayMatches(t time.Time) bool {
dom := s.dom&(1<<uint(t.Day())) != 0
dow := s.dow&(1<<uint(t.Weekday())) != 0
switch {
case s.domAny && s.dowAny:
return true
case s.domAny:
return dow
case s.dowAny:
return dom
default:
return dom || dow
}
}
+50
View File
@@ -0,0 +1,50 @@
package backup
import (
"testing"
"time"
)
func TestNext(t *testing.T) {
utc := func(s string) time.Time {
v, err := time.Parse("2006-01-02 15:04", s)
if err != nil {
t.Fatal(err)
}
return v
}
cases := []struct{ expr, from, want string }{
{"0 3 * * *", "2026-09-30 02:59", "2026-09-30 03:00"},
{"0 3 * * *", "2026-09-30 03:00", "2026-10-01 03:00"},
{"@daily", "2026-12-31 23:30", "2027-01-01 00:00"},
{"*/15 * * * *", "2026-09-30 10:07", "2026-09-30 10:15"},
{"30 4 * * sun", "2026-09-30 00:00", "2026-10-04 04:30"}, // Wed → Sun
{"30 4 * * 7", "2026-09-30 00:00", "2026-10-04 04:30"},
{"0 0 1,15 * *", "2026-09-02 00:00", "2026-09-15 00:00"},
{"0 9 * * mon-fri", "2026-10-02 10:00", "2026-10-05 09:00"}, // Fri after 9 → Mon
{"0 0 29 2 *", "2026-03-01 00:00", "2028-02-29 00:00"},
{"0 12 13 * 5", "2026-09-30 00:00", "2026-10-02 12:00"}, // Friday OR the 13th
{"5/20 * * * *", "2026-09-30 10:26", "2026-09-30 10:45"},
}
for _, c := range cases {
s, err := ParseCron(c.expr)
if err != nil {
t.Fatalf("%s: %v", c.expr, err)
}
if got := s.Next(utc(c.from)); !got.Equal(utc(c.want)) {
t.Errorf("%s from %s = %s, want %s", c.expr, c.from, got.Format("2006-01-02 15:04 Mon"), c.want)
}
}
}
func TestParseCronErrors(t *testing.T) {
for _, expr := range []string{"", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "5-1 * * * *", "*/0 * * * *", "a * * * *"} {
if _, err := ParseCron(expr); err == nil {
t.Errorf("%q: expected an error", expr)
}
}
s, _ := ParseCron("0 0 30 2 *")
if !s.Next(time.Now()).IsZero() {
t.Error("Feb 30 should never run")
}
}
+5 -1
View File
@@ -4,6 +4,7 @@ package config
import (
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -16,6 +17,7 @@ type Config struct {
Host string
Port string
DataDir string
BackupDir string
AdminUsername string
AdminPassword string
SecureCookies bool
@@ -35,11 +37,13 @@ func Load() Config {
// just this machine. Use 127.0.0.1 to keep it local.
host := env("CANON_HOST", "0.0.0.0")
port := env("CANON_PORT", "4242")
dataDir := env("CANON_DATA_DIR", "./data")
return Config{
ListenAddr: env("CANON_LISTEN", net.JoinHostPort(host, port)),
Host: host,
Port: port,
DataDir: env("CANON_DATA_DIR", "./data"),
DataDir: dataDir,
BackupDir: env("CANON_BACKUP_DIR", filepath.Join(dataDir, "backups")),
AdminUsername: env("CANON_ADMIN_USERNAME", ""),
AdminPassword: env("CANON_ADMIN_PASSWORD", ""),
SecureCookies: envBool("CANON_SECURE_COOKIES", false),
+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)
}
+9 -1
View File
@@ -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) })
+2
View File
@@ -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"
+7
View File
@@ -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
}
+8
View File
@@ -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; }
+1
View File
@@ -43,5 +43,6 @@
<symbol id="alert" viewBox="0 0 24 24"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3ZM12 9v4M12 17h.01"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
<symbol id="crown" viewBox="0 0 24 24"><path d="M11.6 3.3a.5.5 0 0 1 .8 0l2.9 5.5a1 1 0 0 0 1.5.3l4.4-3.7a.5.5 0 0 1 .8.5l-2.8 10.4a1 1 0 0 1-1 .7H5.8a1 1 0 0 1-1-.7L2 5.9a.5.5 0 0 1 .8-.5l4.4 3.7a1 1 0 0 0 1.5-.3zM5 21h14"/></symbol>
<symbol id="archive" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M8 16H3v5"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

+124 -3
View File
@@ -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();
}
+1
View File
@@ -12,6 +12,7 @@
<a href="#profile">{{icon "user"}}Profile</a>
<a href="#appearance">{{icon "palette"}}Appearance</a>
<a href="#integrations">{{icon "plug"}}Integrations</a>
<a href="#backups">{{icon "archive"}}Backups</a>
<a href="#account">{{icon "lock"}}Account</a>
</nav>
<div class="settings-body" id="settings" aria-busy="true">