b8af72764d
gates / gates (push) Successful in 11s
Four defects on the restore surface, all proven on demo-hp during the 2026-08-21 backup-truth drill, all still in shipped code. They share one acceptance idea: a restore surface must state what it actually did, and must refuse what it cannot do. VERSION NOTE. The task specifying this targeted v0.224.0 against baselinef8c9390. Both were consumed earlier the same day by R-330 (0.224.0) and R-331 (0.225.0). Drift re-confirmed against live Gitea before the first edit, operator authorised proceeding, every symbol the spec named re-verified present at the real baselinee5eee50. R-353 -- a restore that gave back nothing still said it worked. RestoreFromRecoveryUnit returned only error, so the surface printed "<app> visszaallitva (<snapshot>)." -- equally true of a run that returned an entire dataset and one that returned nothing. The count already existed and was discarded one line deep: restoreDockerVolumesFrom always returned it, the wrapper threw it away. Now (UnitRestoreResult, error), carrying replayed counts AND what the manifest LISTED, because zero-replayed has two causes that are opposite news. Three cases, three sentences, and EVERY one is a claim about the BACKUP, never about the app -- this path has no SafetyDump discriminator, and 07-backup-architecture 6.3 records that an absent dump says nothing about the app (R-361 destroyed canonical .sql files for four months). R-357 -- the destructive restore had no free-space gate. offbox_reconstitute.go contained ZERO references to offboxFree; all three existing gates guard non-destructive paths. The gate now sits before mapOffsiteRestorePaths, writeSafetyDump and StopStack, so a refusal costs nothing. Position IS the fix, which is why the test asserts StopStack was never called. No headroom multiplier (matches PlaceOffsiteRestore; the x1.1 elsewhere predicts a download). Fail closed on either probe <= 0 -- otherwise `free < need` with need==0 is FALSE and an unmeasurable scratch sails through: a gate present and inert. R-358 -- a failed download was offered as a good one. The gate answered "the directory exists and is non-empty", which is exactly what a part-way restic run leaves. Now a completion marker written 0600 atomically AFTER restic returns nil, with any stale one cleared BEFORE it starts; both orders pinned by an AST test because resticStep is not a seam. Both handlers refuse server-side: the wizard flags control a button, and a hidden button is not a guard. SCENARIO F ANSWERED, and worse than the question assumed: a unit-only scratch IS reachable through the real flow, by the most ordinary route. "Ellenorzo visszaallitas" (mode=unit, advertised non-destructive) writes the SAME directory -- offboxRestoreScratchDir ignores `full` and --include limits what restic extracts, never where -- so a customer who ran the SAFE restore was then offered the destructive one over a unit-only copy. Filed R-396; the marker closes it. R-360 -- the delete refused only while a BACKUP ran. IsRunning() is FALSE for the whole of a verification restore; the five sibling handlers all use restoreOpBlocked(). Its doc comment claimed it already did this, which is why nobody looked -- corrected in place. Red-proofs, each printing the pre-fix behaviour, in CHANGELOG and REPORT. The first R-357 red-proof exposed a hollow test OF MY OWN and it is recorded rather than quietly fixed: the fixture refused earlier at the placement stat pre-pass, so `stops == 0` passed against the pre-fix code. Fixture corrected, assertions reordered so a removed gate reports the outage rather than "no error returned". Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
669 lines
35 KiB
Go
669 lines
35 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash.
|
|
// The SSH private key + known-host line are provided out-of-band by the operator (textareas) and written
|
|
// to 0600/0644 files by the backup Manager; they are NEVER echoed back, logged, or stored in settings.
|
|
|
|
// offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or
|
|
// error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the
|
|
// restore-to-verify flow redirects to /backups/restore via offboxRedirectTo).
|
|
func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) {
|
|
offboxRedirectTo(w, r, "/backups/remote", msg, isErr)
|
|
}
|
|
|
|
func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) {
|
|
q := "flash"
|
|
if isErr {
|
|
q = "flash_error"
|
|
}
|
|
// R-48: `page` may already carry a query (the wizard is /backups/restore/app?name=<app>), so the
|
|
// separator has to be chosen, not hardcoded — appending a second "?" produces a URL whose flash
|
|
// silently lands inside the `name` value instead of as its own parameter.
|
|
sep := "?"
|
|
if strings.Contains(page, "?") {
|
|
sep = "&"
|
|
}
|
|
http.Redirect(w, r, page+sep+q+"="+url.QueryEscape(msg), http.StatusFound)
|
|
}
|
|
|
|
// offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts.
|
|
func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil {
|
|
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
host := strings.TrimSpace(r.FormValue("host"))
|
|
user := strings.TrimSpace(r.FormValue("user"))
|
|
repoPath := strings.TrimSpace(r.FormValue("repo_path"))
|
|
port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
|
|
if port == 0 {
|
|
port = 22
|
|
}
|
|
sshKey := r.FormValue("ssh_key")
|
|
knownHosts := r.FormValue("known_hosts")
|
|
|
|
if host == "" || user == "" || repoPath == "" {
|
|
offboxRedirect(w, r, "A cél címe, a felhasználó és a tárhely útvonala kötelező.", true)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(repoPath, "/") {
|
|
offboxRedirect(w, r, "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", true)
|
|
return
|
|
}
|
|
// Validate BEFORE persisting — host/user/repo flow into the ssh command restic runs; reject anything
|
|
// that could inject an ssh option (leading '-') or a metacharacter (the security boundary).
|
|
if err := backup.ValidateOffboxTarget(&settings.OffboxTarget{Host: host, User: user, RepoPath: repoPath, Port: port}); err != nil {
|
|
offboxRedirect(w, r, "Érvénytelen beállítás: "+err.Error(), true)
|
|
return
|
|
}
|
|
// First-time config requires the SSH key + a pinned known-host line (no blind TOFU).
|
|
existing := s.backupMgr.OffboxConfigured()
|
|
if !existing && (strings.TrimSpace(sshKey) == "" || strings.TrimSpace(knownHosts) == "") {
|
|
offboxRedirect(w, r, "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", true)
|
|
return
|
|
}
|
|
|
|
// Write secrets out-of-band (0600 key/pw, 0644 known_hosts); never logged.
|
|
if err := s.backupMgr.WriteOffboxSecrets(sshKey, knownHosts); err != nil {
|
|
s.logger.Printf("[ERROR] [web] offbox secrets: %v", err)
|
|
offboxRedirect(w, r, "A hitelesítő adatok mentése sikertelen.", true)
|
|
return
|
|
}
|
|
|
|
prev := s.settings.GetOffboxTarget()
|
|
tgt := &settings.OffboxTarget{
|
|
Enabled: r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true",
|
|
Host: host, Port: port, User: user, RepoPath: repoPath,
|
|
Schedule: "daily",
|
|
}
|
|
if prev != nil { // preserve runtime status fields across an edit
|
|
tgt.LastRun, tgt.LastStatus, tgt.LastError = prev.LastRun, prev.LastStatus, prev.LastError
|
|
// R-100: LastSuccess is runtime status like the rest — an edit to the host/path/schedule must
|
|
// not erase the staleness anchor. Losing it here would silently reset the tier to "never
|
|
// succeeded" on a routine settings save. Pinned by TestOffboxEdit_PreservesLastSuccess.
|
|
tgt.LastSuccess = prev.LastSuccess
|
|
tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount
|
|
tgt.LastWarning = prev.LastWarning
|
|
tgt.EscrowState = prev.EscrowState
|
|
tgt.RepoSizeBytes = prev.RepoSizeBytes
|
|
tgt.StatsKnown = prev.StatsKnown // R-225: preserved with the numbers it qualifies
|
|
tgt.EnlargedBlocked = prev.EnlargedBlocked
|
|
}
|
|
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
|
|
// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
|
|
// escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push
|
|
// failure does NOT mark escrowed; it is surfaced (the run gate still protects data).
|
|
stageErr := ""
|
|
if tgt.Enabled {
|
|
if tgt.EscrowState != "escrowed" {
|
|
tgt.EscrowState = "pending"
|
|
}
|
|
if client, cerr := s.agentClient(); cerr != nil {
|
|
stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
|
|
s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr)
|
|
} else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil {
|
|
stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra."
|
|
s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret
|
|
}
|
|
}
|
|
if err := s.settings.SetOffboxTarget(tgt); err != nil {
|
|
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
|
|
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
|
|
offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
|
|
}
|
|
|
|
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4).
|
|
// DEPRECATED FALLBACK (SLICE 3): the PRIMARY path is the hub-verified auto-confirm
|
|
// (report.EscrowAutoConfirmer — flips on a hash match in the report ACK, no operator involved). This
|
|
// manual endpoint stays for LEGACY blobs recorded before the hash existed (e.g. the demo's) — they have
|
|
// no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony.
|
|
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
|
o.EscrowState = "escrowed"
|
|
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
|
|
}); err != nil {
|
|
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
|
|
// Fork-4 hygiene: the staged copy on the agent has served its purpose — wipe it. Best-effort: a wipe
|
|
// failure is logged LOUDLY but does not fail the confirm (the state flip is the primary effect; a
|
|
// lingering file is a hygiene gap, not a correctness one — re-confirm retries the wipe).
|
|
if err := s.wipeStagedEscrow(r.Context()); err != nil {
|
|
s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err)
|
|
}
|
|
offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.", false)
|
|
}
|
|
|
|
// wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the
|
|
// pinned local-API channel (agent >= v0.78.0).
|
|
func (s *Server) wipeStagedEscrow(ctx context.Context) error {
|
|
if s.wipeStagedEscrowFn != nil {
|
|
return s.wipeStagedEscrowFn(ctx)
|
|
}
|
|
client, err := s.agentClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
return client.WipeStagedEscrowSecret(wctx)
|
|
}
|
|
|
|
// offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR
|
|
// seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value
|
|
// is never logged. Body: {password, force?}.
|
|
func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil {
|
|
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
pw := r.FormValue("password")
|
|
force := r.FormValue("force") == "on" || r.FormValue("force") == "true"
|
|
if strings.TrimSpace(pw) == "" {
|
|
offboxRedirect(w, r, "A repo jelszó kötelező.", true)
|
|
return
|
|
}
|
|
if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil {
|
|
offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force)
|
|
offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false)
|
|
}
|
|
|
|
// offboxToggleHandler flips an app's off-box inclusion.
|
|
func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
app := strings.TrimSpace(r.FormValue("app"))
|
|
on := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
|
|
if app == "" {
|
|
offboxRedirect(w, r, "Hiányzó alkalmazás.", true)
|
|
return
|
|
}
|
|
if err := s.settings.SetAppOffbox(app, on); err != nil {
|
|
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
|
|
return
|
|
}
|
|
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
|
|
offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
|
|
}
|
|
|
|
// offboxRunHandler triggers an off-box backup now (async — it can run for minutes).
|
|
func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
// fork-4 atomicity: refuse the run until the repo password is escrowed under R.
|
|
if !s.backupMgr.OffboxRunnable() {
|
|
offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
|
|
return
|
|
}
|
|
// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
|
|
// orphan card's explanation/reset instead of attempting a doomed write.
|
|
if s.backupMgr.OffboxOrphaned() {
|
|
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
|
|
return
|
|
}
|
|
// R-234: the single-flight decision is taken SYNCHRONOUSLY, before the goroutine, so the customer
|
|
// is told what actually happened to THEIR request. Deciding it inside the goroutine is what made
|
|
// the drop invisible: the handler had already answered „elindult" and the page then showed the
|
|
// PREVIOUS run's „✓ Rendben".
|
|
// IsRunning() is the CONCURRENCY flag — the very one acquireRunning guards — which is what this
|
|
// question is about. (The documented "use RestoreStatus for display" trap is a different question.)
|
|
if s.backupMgr.IsRunning() {
|
|
s.logger.Printf("[INFO] [web] manual off-box backup NOT started for this request: a run is already in flight")
|
|
offboxRedirect(w, r, "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.", true)
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
|
|
defer cancel()
|
|
// ...WithProgress: this is the MANUAL trigger, so the page gets live bytes/percent/current app
|
|
// (4c). The nightly scheduler keeps calling RunOffboxBackup and stays silent.
|
|
if err := s.backupMgr.RunOffboxBackupWithProgress(ctx); err != nil {
|
|
if errors.Is(err, backup.ErrOffboxRunInFlight) {
|
|
// Lost the race between the check above and acquireRunning — rare, and still not a failure.
|
|
s.logger.Printf("[INFO] [web] manual off-box backup dropped by the single-flight (raced)")
|
|
return
|
|
}
|
|
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
|
|
}
|
|
}()
|
|
offboxRedirect(w, r, "A távoli mentés elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
|
|
// code-recoverable) history aside — never delete — and init a fresh repo. Refuses unless orphaned AND
|
|
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
|
|
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
if !s.backupMgr.OffboxOrphaned() {
|
|
offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
|
|
return
|
|
}
|
|
if r.FormValue("confirm") != "1" {
|
|
offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.ResetOrphanedRepo(ctx); err != nil {
|
|
s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
|
|
}
|
|
}()
|
|
offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
|
|
}
|
|
|
|
// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
|
|
// after "Távoli mentés most" and flips to the terminal state without a manual reload. Session-auth'd.
|
|
func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
t := s.settings.GetOffboxTarget()
|
|
resp := map[string]any{"status": "", "snapshots": 0, "orphaned": false}
|
|
if t != nil {
|
|
resp["status"] = t.LastStatus
|
|
resp["snapshots"] = t.SnapshotCount
|
|
resp["last_run"] = t.LastRun
|
|
resp["last_duration"] = t.LastDuration
|
|
resp["repo_size_human"] = t.RepoSizeHuman
|
|
resp["last_error"] = t.LastError
|
|
resp["orphaned"] = t.RepoState == "orphaned"
|
|
}
|
|
// v0.147.0 (4c): live progress for a MANUAL run. Absent/inactive on the nightly path, so the
|
|
// page simply keeps its previous „Fut…" behavior there.
|
|
if s.backupMgr != nil {
|
|
resp["progress"] = s.backupMgr.OffboxProgressSnapshot()
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// offboxRestoreHandler restores an app's off-box data to an on-data-drive scratch dir (§7, F-A1;
|
|
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
|
|
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects
|
|
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
|
|
func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
app := strings.TrimSpace(r.FormValue("app"))
|
|
if app == "" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(r.FormValue("mode"))
|
|
if mode == "" {
|
|
mode = "unit"
|
|
}
|
|
// Step 1 of the full two-step: compute size + headroom BEFORE any restic restore; on a refusal
|
|
// flash the Hungarian reason, else redirect with the reveal params (size shown before starting).
|
|
if mode == "full" && r.FormValue("confirm") != "1" {
|
|
pctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
|
|
if err != nil {
|
|
// R-238: this leg starts no job, so it appears NOWHERE in the restore-op status. Until
|
|
// v0.204.0 it also logged nothing, which made a refused disaster restore — including a
|
|
// refusal by the headroom gate — completely invisible on the box: no error, no line, and
|
|
// a redirect that lands the customer back where they started. Diagnosing a silence is
|
|
// what this project has spent a fortnight removing.
|
|
s.logger.Printf("[WARN] [web] off-box full-restore preparation REFUSED for %s (no job started): %v", app, err)
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
|
|
return
|
|
}
|
|
// The success half is logged too: it is the step that decides the customer may proceed, and
|
|
// "the size gate passed at N" is the line that explains the confirm they were then shown.
|
|
s.logger.Printf("[INFO] [web] off-box full-restore prepared for %s (size %s) — awaiting the customer's confirm; no restore has started", app, sizeHuman)
|
|
// R-48: the size-gate reveal now lands on the app's wizard (prepare-confirm step) rather than
|
|
// on the list page. Same params, same meaning — only the surface that renders them changed.
|
|
http.Redirect(w, r, restoreWizardPath(app)+"&full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
|
|
return
|
|
}
|
|
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
|
|
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
// Same silence class as the size gate: a refusal that starts nothing must still be findable.
|
|
s.logger.Printf("[WARN] [web] off-box restore refused for %s (mode=%s): another backup/restore op is already running", app, mode)
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
|
|
return
|
|
}
|
|
full := mode == "full"
|
|
s.backupMgr.BeginRestoreOp("offbox-restore", app)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
|
|
s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
|
|
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
|
|
// NAME THE RESULT (v0.147.0, 4a). The old message said the app had been restored "to a
|
|
// verification folder on the drive" — which folder, on which drive, was invisible, so the
|
|
// customer had no way to look at what they had just asked for. Resolve the real path and say
|
|
// it. Fall back to the vague wording only if the path can no longer be resolved.
|
|
where := s.backupMgr.OffsiteRestoreScratchPath(app)
|
|
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
|
|
}()
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// restoreScratchOutcomeMsg builds the OUTCOME flash for a completed scratch restore. Pure, so the
|
|
// wording is unit-testable — this string is the customer's only evidence of WHAT they now have.
|
|
//
|
|
// R-204 item 3 (v0.198.0) — THE DEFECT IT CLOSES. The default restore (`mode=unit`) recovers the
|
|
// recovery unit: the app's definition, its configuration and its database dumps. It does NOT recover
|
|
// the customer's own files; `RestoreOffboxScratch` passes `--include <unit path>` and the userdata
|
|
// paths that ARE in the same snapshot are excluded by it. The old message was one sentence for both
|
|
// modes and named neither scope, so a customer on the last step of a disaster recovery was told
|
|
// „visszaállítva" after the thing they were looking for had not been restored. A success message
|
|
// that does not name its scope is a silent wrong answer, which is this project's most-repeated
|
|
// failure shape.
|
|
//
|
|
// So the unit case states three things in order: what came back, what did NOT, and the next step
|
|
// that gets it. The full case says the files came with it, because otherwise the absence of the
|
|
// warning would be the only difference and an absence is not a statement.
|
|
func restoreScratchOutcomeMsg(app, where string, full bool) string {
|
|
at := " ellenőrző mappába"
|
|
if where != "" {
|
|
at = " ellenőrző mappába: " + where
|
|
}
|
|
if full {
|
|
return "A(z) " + app + " teljes mentése visszaállítva" + at +
|
|
" — a saját fájljaiddal együtt. A meglévő adatok változatlanok."
|
|
}
|
|
return "A(z) " + app + " beállításai és adatbázisa visszaállítva" + at +
|
|
". A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. " +
|
|
"Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."
|
|
}
|
|
|
|
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
|
|
// snapshot's version + that same snapshot's database replayed + the app restarted, with a safety
|
|
// dump of the current database taken first.
|
|
//
|
|
// It is a separate button from the missing-only place, not a flag on it. The two do opposite things
|
|
// to an existing file, and the v0.147 flash („hiányzó fájljai helyreállítva") described a mechanism
|
|
// that could report success after merging zero files while the customer's photos stayed invisible.
|
|
// The flash here states the OUTCOME instead — file count, database, backup timestamp, restart —
|
|
// because that is the only part the customer can check against what they see in the app.
|
|
func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
app := strings.TrimSpace(r.FormValue("app"))
|
|
if app == "" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
|
|
return
|
|
}
|
|
// This one overwrites live files and replays a database — it must never happen on a stray click.
|
|
if r.FormValue("confirm") != "1" {
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
|
|
return
|
|
}
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
|
|
return
|
|
}
|
|
// R-358: the same server-side refusal as the place handler, and it matters MORE here — this is the
|
|
// destructive path. On 2026-08-21 „Teljes visszaállítás indítása" was offered over a part-copy left
|
|
// by a failed download and reported ok=true. The wizard's RestoreEnabled flag controls a button;
|
|
// this controls the operation.
|
|
if !s.backupMgr.OffboxFullScratchReady(app) {
|
|
s.logger.Printf("[WARN] [web] off-box reconstitute refused for %s: the restore scratch carries no completion marker", app)
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
|
return
|
|
}
|
|
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
|
|
// data"; this one answers "yes, into a different place than the backup recorded". One checkbox
|
|
// carrying both would be the two-decisions-one-button shape R-48 removed from this surface.
|
|
ackPlacement := r.FormValue("ack_placement") == "1"
|
|
s.backupMgr.BeginRestoreOp("offbox-reconstitute", app)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
|
defer cancel()
|
|
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app, ackPlacement)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
|
|
s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
|
|
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
|
|
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
|
|
}()
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// offsiteScratchIncompleteMsg (R-358) is the server-side refusal shown when a place or reconstitute is
|
|
// attempted over a scratch that carries no completion marker. Named because two handlers assert it and
|
|
// two tests assert it verbatim. It names the action that works — Lane 1 is customer-owned, so a refusal
|
|
// that leaves the customer with no next step is not a refusal, it is a dead end.
|
|
const offsiteScratchIncompleteMsg = "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését."
|
|
|
|
// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
|
|
// wording is unit-testable — this string is the customer's only evidence that the operation did
|
|
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
|
|
// than borrowing the confident sentence that belongs to the full case.
|
|
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) string {
|
|
when := ""
|
|
if !res.DumpsAt.IsZero() {
|
|
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
|
|
}
|
|
// R-354 — WHAT ACTUALLY CAME BACK, NAMED. The volume leg is stated whenever it returned anything,
|
|
// because a restore that replayed an app's entire dataset and mentioned only its file count is
|
|
// precisely how a silent loss reads as a success: on 2026-08-21 calibre-web was told
|
|
// „5 fájl visszaállítva" over a run that had dropped a 1 422 848-byte volume archive. Every clause
|
|
// here is conditional on having done the thing, so a snapshot with no volumes produces the exact
|
|
// sentence it produced before (pinned by TestReconstituteOutcome_NoVolumesWordingUnchanged).
|
|
what := fmt.Sprintf("%d fájl", res.FilesPlaced)
|
|
if res.VolumesReplayed > 0 {
|
|
what += fmt.Sprintf(" és %d adatkötet", res.VolumesReplayed)
|
|
}
|
|
if res.DBsReplayed > 0 {
|
|
what += " és az adatbázis"
|
|
}
|
|
msg := fmt.Sprintf("A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", app, what, when)
|
|
|
|
if res.DBsReplayed == 0 {
|
|
// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
|
|
// class of sentence the DIAG found being printed over a no-op.
|
|
//
|
|
// R-355: „nincs adatbázisa" is a claim ABOUT THE APP and must not be inferred from a counter.
|
|
// `DBsReplayed == 0` has two causes — the app has no database, or it has one and the snapshot
|
|
// carried no dump for it — and until now both printed the same confident sentence. On
|
|
// 2026-08-21 that sentence was shown over a live 72-table PostgreSQL the controller had dumped
|
|
// five minutes earlier. `SafetyDump` is the honest discriminator: writeSafetyDump returns a
|
|
// path only when a live database for this app was found AND successfully dumped, so a non-empty
|
|
// value proves the app HAS one. Same counter, two different facts, and now two sentences.
|
|
if res.SafetyDump != "" {
|
|
return msg + fmt.Sprintf(" FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s", filepath.Base(res.SafetyDump))
|
|
}
|
|
return msg + " Ennek az alkalmazásnak nincs adatbázisa."
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
|
|
//
|
|
// The only delete this slice adds, so it is deliberately narrow: it names a STACK, never a path — the
|
|
// customer cannot hand us a path to remove. The Manager resolves that name inside a
|
|
// `backups/offsite-restore` root it computed itself and refuses anything that lands outside (see
|
|
// DeleteOffsiteRestoreCopy). The template double-confirms before POSTing.
|
|
//
|
|
// R-360 — THE DOC COMMENT USED TO CLAIM THIS AND THE CODE DID NOT DO IT, which is why nobody looked.
|
|
// It read "It refuses while a backup/restore op is running"; the guard was `s.backupMgr.IsRunning()`,
|
|
// which answers "is a BACKUP running" and is FALSE for the whole of a verification restore (see the
|
|
// standing note on the two running flags). Its five siblings on this surface all used
|
|
// `restoreOpBlocked()`, which consults both; this one was missed. Observed live 2026-08-21 22:35:
|
|
// `RestoreStatus().Running == true` while `IsRunning() == false`, and the delete of the copy the
|
|
// restore was writing into went through.
|
|
//
|
|
// It now refuses while ANY backup or restore op is running.
|
|
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
stack := strings.TrimSpace(r.FormValue("stack"))
|
|
if stack == "" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
|
|
return
|
|
}
|
|
if r.FormValue("confirm") != "1" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
|
|
return
|
|
}
|
|
// R-360: `restoreOpBlocked()` and not `IsRunning()`. No app-name comparison is added deliberately:
|
|
// refusing during ANY restore is strictly stronger than refusing only for the restoring app, and it
|
|
// is the rule the other five handlers on this surface already follow. Uniformity is worth more than
|
|
// precision here — the defect was one handler being different.
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
s.logger.Printf("[WARN] [web] verification-copy delete refused for %s: a backup/restore op is running", stack)
|
|
offboxRedirectTo(w, r, "/backups/restore", msg, true)
|
|
return
|
|
}
|
|
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
|
|
s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
|
|
offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
|
|
offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
|
|
}
|
|
|
|
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
|
|
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
|
|
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
app := strings.TrimSpace(r.FormValue("app"))
|
|
if app == "" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
|
|
return
|
|
}
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
|
|
return
|
|
}
|
|
// R-358: refuse SERVER-SIDE over an incomplete scratch. The wizard already hides the button when
|
|
// PlaceEnabled is false — and a hidden button is not a guard. This handler is reachable by a POST,
|
|
// and before v0.226.0 a POST over a failed download was accepted and reported success.
|
|
if !s.backupMgr.OffboxFullScratchReady(app) {
|
|
s.logger.Printf("[WARN] [web] off-box place refused for %s: the restore scratch carries no completion marker", app)
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
|
return
|
|
}
|
|
s.backupMgr.BeginRestoreOp("offbox-place", app)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
|
|
s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
|
|
s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
|
|
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
|
|
}()
|
|
offboxRedirectTo(w, r, restoreWizardPath(app), "A helyreállítás elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
|
|
//
|
|
// A SIBLING of the per-app restore pair above, not a special case of it: the shares source has no
|
|
// recovery unit and no per-app toggle, so it gets its own two-step flow (restore to scratch, then a
|
|
// deliberate place-to-live). The display name is always „Megosztások" — the reserved `_shares` key
|
|
// never reaches a customer-facing surface.
|
|
|
|
// sharesRestoreHandler restores the latest shares snapshot into an on-data-drive scratch dir
|
|
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
|
|
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
offboxRedirectTo(w, r, "/backups/restore", msg, true)
|
|
return
|
|
}
|
|
s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
|
|
s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
|
|
s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] shares restore completed (async)")
|
|
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
|
|
}()
|
|
offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
|
|
// missing definitions and restores the household credential (POST /backup/shares/place).
|
|
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
if msg, blocked := s.restoreOpBlocked(); blocked {
|
|
offboxRedirectTo(w, r, "/backups/restore", msg, true)
|
|
return
|
|
}
|
|
s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
res, err := s.backupMgr.PlaceSharesRestore(ctx)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
|
|
s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
|
|
res.FilesRestored, len(res.DefinitionsAdded))
|
|
s.backupMgr.EndRestoreOp(true, res.FlashMessage())
|
|
}()
|
|
offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
|
|
}
|