It has run since the beginning and nothing showed it. A backup nobody can see is a backup nobody knows has been failing, and the failure is quiet: a volume gone read-only, a disk that filled, a container that has not been up at midnight all week. The first anybody hears of it is the day they need one. `ListBackupRuns` was already in the store and exposed nowhere. Now there is a list, a download, and a button that takes one now — the same work the nightly job does without its "one a day" check, because waiting until tonight to learn whether backups work at all is how somebody finds out on the day they need one, and it is the only way to get a snapshot from *before* something you are about to do. Restoring is deliberately not a button. Swapping a database under a server that is writing to it means stopping every writer, replacing the file and reopening, and a half-done version of that destroys the thing it was restoring. The panel says what to do instead. `RunBackupNow` turns out to have been a second copy of the backup logic, written for a button that did not exist yet, and the two had already drifted: it never created the backups directory and logged nothing. One implementation now. Administrators only, and sessions only. A backup is a complete copy of the database, so this hands out everybody's data in one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
5.1 KiB
Go
159 lines
5.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/jobs"
|
|
)
|
|
|
|
// The nightly backup, made visible.
|
|
//
|
|
// It has run since the beginning and nothing showed it. A backup nobody can see is
|
|
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
|
|
// that went read-only, a disk that filled, a container that has not been up at
|
|
// midnight for a week. The first time anybody finds out is the day they need one.
|
|
//
|
|
// Administrators only, and behind a session as well as the admin check. A backup
|
|
// file is a complete copy of the database, so downloading one is downloading
|
|
// everybody's data; a leaked API token must not be able to.
|
|
|
|
type backupRunJSON struct {
|
|
ID string `json:"id"`
|
|
Started string `json:"started_at"`
|
|
// Finished is absent while a backup is still running, and on one that was
|
|
// interrupted — which is the same shape from outside and honestly so: a run
|
|
// the process died in the middle of never finished.
|
|
Finished string `json:"finished_at,omitempty"`
|
|
Size int64 `json:"size_bytes"`
|
|
Error string `json:"error,omitempty"`
|
|
// Present says the file is still on disk. Rotation keeps the most recent
|
|
// fourteen, so an older row is a true record of a backup that was made and a
|
|
// file that is no longer there — and a download link for it would 404.
|
|
Present bool `json:"present"`
|
|
}
|
|
|
|
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
|
|
if err != nil {
|
|
s.internal(w, r, "list backups", err)
|
|
return
|
|
}
|
|
|
|
out := make([]backupRunJSON, 0, len(runs))
|
|
for _, b := range runs {
|
|
j := backupRunJSON{
|
|
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
|
Size: b.SizeBytes, Error: b.Error,
|
|
}
|
|
if b.FinishedAt != nil {
|
|
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
|
}
|
|
if b.Path != "" {
|
|
if _, err := os.Stat(b.Path); err == nil {
|
|
j.Present = true
|
|
}
|
|
}
|
|
out = append(out, j)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
|
|
}
|
|
|
|
// handleRunBackup takes one now.
|
|
//
|
|
// The same function the nightly job runs, without the "has one run today" check.
|
|
// Waiting until tonight to learn whether backups work at all is how somebody finds
|
|
// out on the day they need one — and it is the only way to get a snapshot from
|
|
// *before* something you are about to do.
|
|
//
|
|
// Synchronous. A backup of a homelab database is a second or two, and a button
|
|
// that returns immediately and tells you nothing is a button you press twice.
|
|
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
|
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
|
|
s.internal(w, r, "run backup", err)
|
|
return
|
|
}
|
|
runs, err := s.db.ListBackupRuns(r.Context(), 1)
|
|
if err != nil || len(runs) == 0 {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
b := runs[0]
|
|
j := backupRunJSON{
|
|
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
|
Size: b.SizeBytes, Error: b.Error, Present: true,
|
|
}
|
|
if b.FinishedAt != nil {
|
|
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
|
}
|
|
writeJSON(w, http.StatusCreated, j)
|
|
}
|
|
|
|
// handleDownloadBackup sends one backup file.
|
|
//
|
|
// The path comes from the row rather than from the request, and is then checked to
|
|
// be inside the backups directory anyway. The row is written by this process and
|
|
// should always be safe; the check is there because "should always" is how a path
|
|
// traversal gets shipped, and the thing on the other side of it is every account's
|
|
// data in one file.
|
|
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
|
runs, err := s.db.ListBackupRuns(r.Context(), 100)
|
|
if err != nil {
|
|
s.internal(w, r, "list backups", err)
|
|
return
|
|
}
|
|
|
|
id := chi.URLParam(r, "backupID")
|
|
var path string
|
|
for _, b := range runs {
|
|
if b.ID == id {
|
|
path = b.Path
|
|
break
|
|
}
|
|
}
|
|
if path == "" {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
dir, err := filepath.Abs(s.cfg.BackupsDir())
|
|
if err != nil {
|
|
s.internal(w, r, "backups dir", err)
|
|
return
|
|
}
|
|
full, err := filepath.Abs(path)
|
|
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
file, err := os.Open(full)
|
|
if err != nil {
|
|
// Rotation keeps the most recent fourteen, so a row older than that is a
|
|
// true record with no file behind it. Not an error — a 404 is the honest
|
|
// answer to "send me that one".
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
s.internal(w, r, "stat backup", err)
|
|
return
|
|
}
|
|
|
|
// The same treatment attachments get, and for a stronger reason: this is a
|
|
// SQLite database, and nothing should ever be tempted to render it inline.
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition",
|
|
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
http.ServeContent(w, r, "", info.ModTime(), file)
|
|
}
|