2e936f43bf
mode=unit restores the recovery unit — the app's definition, configuration and database dumps — and NOT the customer's own files: RestoreOffboxScratch passes --include <unit path> and the userdata in the same snapshot is excluded by it. The outcome was one sentence for both modes and named neither scope, so on the last step of a disaster recovery the customer was told the app had been restored after the thing they were looking for had not been. restoreScratchOutcomeMsg states what came back, what did not, and the next step that gets it. The wizard's intent card states its scope before the choice. The full-restore size gate is untouched and pinned as unchanged; the default stays unit, since all three wizard forms set mode explicitly.
574 lines
28 KiB
Go
574 lines
28 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
|
|
// 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.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)
|
|
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)
|
|
}
|