Files
felhom-controller/controller/internal/web/offbox_handlers.go
T
admin 3a9d744360 v0.154.0 — R-48: one restore entry per app, and the intent is a described choice
The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).

The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.

Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.

deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.

No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.

Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.

No new agent coupling — MinAgent stays 0.90.0.
2026-07-21 08:30:42 +02:00

545 lines
26 KiB
Go

package web
import (
"context"
"encoding/json"
"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
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.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
}
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 {
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 {
offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
return
}
// 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() {
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)
msg := "A(z) " + app + " visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok)."
if where != "" {
msg = "A(z) " + app + " visszaállítva ellenőrző mappába: " + where + " (a meglévő adatok változatlanok)."
}
s.backupMgr.EndRestoreOp(true, msg)
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
}
// 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)
}