fix(api): F1 — register GET /api/backup/snapshots so the restore panel can populate

The backups.html restore panel fetched /api/backup/snapshots (a restic-era route
that no longer existed), so the snapshot dropdown never populated and the
"Visszaállítás indítása" button could never enable — customers could not restore
anything from the UI (drill finding F1, DRILL-appdata-restore-2026-07-04).

- backup.Manager.ListRestorePoints: the keep-side restore has exactly ONE restore
  point per app (the current recovery unit); time = newest artifact mtime among
  manifest/db-dumps/volume-dumps; tier always 1 (Tier-2 copies are NOT restorable
  via POST /backup/restore — never listed); drive_label from the storage registry,
  empty for the SSD fallback.
- api: /backup/snapshots route + validStackParam guard (same semantics as
  web.validStackName; traversal → 400, unknown stack → 404, no unit → ok+[]).
- Tests dispatch through Router.ServeHTTP (the bug WAS a missing route) + unit
  tests for newest-mtime/label/empty semantics. Companion red-proof: hollow
  always-[] implementation fails TestListRestorePoints_UnitOnDisk +
  TestBackupSnapshots_UnitOnDisk (verified, reverted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 11:42:39 +02:00
parent a39ab65eb2
commit f413f9539d
4 changed files with 417 additions and 0 deletions
+42
View File
@@ -252,6 +252,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/backup/status" && req.Method == http.MethodGet:
r.backupStatus(w, req)
// GET /api/backup/snapshots?stack=<name> — restorable keep-side backups for the restore panel
case path == "/backup/snapshots" && req.Method == http.MethodGet:
r.backupSnapshots(w, req)
// POST /api/backup/run
case path == "/backup/run" && req.Method == http.MethodPost:
r.triggerBackup(w, req)
@@ -831,6 +835,44 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// validStackParam reports whether a stack name from a request is a safe single path segment
// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because
// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become
// a filesystem path outside the app's namespace root.
func validStackParam(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
if strings.ContainsAny(name, "/\\\x00") {
return false
}
return name == filepath.Clean(name)
}
// backupSnapshots lists the restorable keep-side backups for one app — the data source of the
// /backups restore panel's snapshot dropdown (F1: this route was fetched by the template but never
// existed, so the dropdown could never populate and the restore button never enabled).
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
stack := req.URL.Query().Get("stack")
if !validStackParam(stack) {
r.dbg("backupSnapshots: invalid stack param %q", stack)
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
return
}
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
points, found := r.backupMgr.ListRestorePoints(stack)
if !found {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + stack})
return
}
r.dbg("backupSnapshots: stack=%s points=%d", stack, len(points))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: points})
}
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {