5ce3a44645
gates / gates (push) Successful in 11s
R-355 (first, because it is the only one where data can be lost for good). paperless-ngx's PostgreSQL was dumped into backups/primary/paperless/db-dumps/ — a directory for a stack that does not exist, on the system drive — while the app's own unit recorded db_dumps: null. The same misattribution reached writeSafetyDump, so a destructive restore of that app took NO undo copy and the fail-closed refusal was never reached. Fixed by reading the compose project label, which is the stack name by construction (compose runs with cmd.Dir set to the stack dir and no -p). The old derivation stays as the fallback and an unresolvable attribution is now loud. Catalogue sweep, proven able to convict: one affected app of 53. The fix is in the controller, not the catalogue. R-354. ReconstituteFromOffsite skipped every unit placement and the volume archives live inside the unit, so the off-site restore had no volume leg at all — proven live with planted files: calibre-web's 1,422,848-byte config archive was in the unit, the snapshot and the checking folder, and the restore reported success without it. For the 40 of 53 apps that declare no data drive that archive is the whole dataset. restoreDockerVolumesFrom is the local path's own replay with an explicit directory: ONE implementation, two callers. Volumes replay before the database and inside the stopped window. VolumesReplayed reaches the message. The comment beside the skip was half false and is corrected; the half that still holds — the live unit is the local path's source — is named, and scenario D fingerprints the whole live unit across the operation. Seven red-proofs, each asserted applied and reverted. Two found defects in the tests, not the code: scenario D passed with the unit guard removed because the fingerprint had been narrowed and was blind to the unit root.
634 lines
32 KiB
Go
634 lines
32 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-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)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// It refuses while a backup/restore op is running: the copy being deleted could be the one currently
|
|
// being written.
|
|
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
|
|
}
|
|
if s.backupMgr.IsRunning() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet fut — a törlés most nem biztonságos.", 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
|
|
}
|
|
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)
|
|
}
|