Files
felhom-controller/controller/internal/web/offbox_handlers.go
T
admin c6b69d888e
gates / gates (push) Successful in 21s
v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
THE VERDICT. The R-203 block already said "a warning beside a success is read as a
success" and applied it to ONE of the two shapes it describes: an app missing a
declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY
still reported ok. Both do now. Which skips count, decided by measurement:
selected+deployed with no recovery unit YES; selected but NOT deployed no (named,
with what to do — a box left amber by an app somebody removed is a status nobody
reads); disconnected/decommissioned drive no (own signal); nothing selected no.
LastSuccess and SnapshotCount still record what WAS captured.

THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3
stated that toggling an app on leaves it without a bundle so the first run skips
it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits
for every DEPLOYED stack, through admitApp, before the push — a unit moved aside
was RECREATED and the run reported ok. That state does not survive a run.

What actually produced the 2026-08-06 sequence: the manual run was dropped by the
single-flight while an earlier run was still going. runOffboxBackup returned nil,
the handler had already answered "A tavoli mentes elindult", and the card then
showed the PREVIOUS run's green verdict — read as covering the app just selected.
The decision is now taken synchronously in the handler and a dropped request says
so. The nightly path still returns nil on purpose: nobody asked, and it retries.

§7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose +
manifest, only ENUMERATES dumps rather than creating them, is idempotent and does
NOT stop the app — and already runs inside the off-site run. So there is no wait to
remove for a deployed app and NOTHING was built.

28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture
note: the shared provider's ListDeployedStacks returned nil, so Scenario A first
passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
2026-08-06 21:58:21 +02:00

603 lines
30 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"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 s.backupMgr.IsRunning() {
// 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), "Egy mentési/visszaállítási művelet már fut.", 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 s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
return
}
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)
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") + ")"
}
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.
return fmt.Sprintf("A(z) %s: %d fájl visszaállítva%s — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa.", app, res.FilesPlaced, when)
}
return fmt.Sprintf("A(z) %s: %d fájl és az adatbázis visszaállítva%s — az alkalmazás újraindult.", app, res.FilesPlaced, when)
}
// 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 s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", 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 s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", 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 s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", 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)
}