v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash.
|
||||
@@ -377,7 +377,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
|
||||
@@ -386,7 +386,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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))
|
||||
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full, s.boxLang()))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.restore_started", false)
|
||||
}
|
||||
@@ -406,18 +406,15 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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"
|
||||
func restoreScratchOutcomeMsg(app, where string, full bool, lang string) string {
|
||||
at := util.Text(lang, "note.restore.at_scratch")
|
||||
if where != "" {
|
||||
at = " ellenőrző mappába: " + where
|
||||
at = util.Text(lang, "note.restore.at_scratch_named", 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 util.Text(lang, "note.restore.scratch_full", app, at)
|
||||
}
|
||||
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."
|
||||
return util.Text(lang, "note.restore.scratch_state", app, at)
|
||||
}
|
||||
|
||||
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
|
||||
@@ -455,7 +452,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
// this controls the operation.
|
||||
if !s.backupMgr.OffboxFullScratchReady(app) {
|
||||
s.logger.Printf("[WARN] [web] off-box reconstitute refused for %s: the restore scratch carries no completion marker", app)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
|
||||
return
|
||||
}
|
||||
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
|
||||
@@ -469,12 +466,12 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
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())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_failed", s.noteErr(err)))
|
||||
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))
|
||||
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res, s.boxLang()))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
|
||||
}
|
||||
@@ -483,16 +480,16 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
// attempted over a scratch that carries no completion marker. Named because two handlers assert it and
|
||||
// two tests assert it verbatim. It names the action that works — Lane 1 is customer-owned, so a refusal
|
||||
// that leaves the customer with no next step is not a refusal, it is a dead end.
|
||||
const offsiteScratchIncompleteMsg = "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését."
|
||||
const offsiteScratchIncompleteKey = "note.restore.scratch_incomplete"
|
||||
|
||||
// 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 {
|
||||
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult, lang string) string {
|
||||
when := ""
|
||||
if !res.DumpsAt.IsZero() {
|
||||
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
|
||||
when = util.Text(lang, "note.restore.dumps_at", 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
|
||||
@@ -500,14 +497,14 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
|
||||
// „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)
|
||||
what := util.Text(lang, "note.restore.files_count", res.FilesPlaced)
|
||||
if res.VolumesReplayed > 0 {
|
||||
what += fmt.Sprintf(" és %d adatkötet", res.VolumesReplayed)
|
||||
what += util.Text(lang, "note.restore.and_volumes", res.VolumesReplayed)
|
||||
}
|
||||
if res.DBsReplayed > 0 {
|
||||
what += " és az adatbázis"
|
||||
what += util.Text(lang, "note.restore.and_database")
|
||||
}
|
||||
msg := fmt.Sprintf("A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", app, what, when)
|
||||
msg := util.Text(lang, "note.restore.reconstituted", 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
|
||||
@@ -521,9 +518,9 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
|
||||
// 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 + util.Text(lang, "note.restore.no_db_in_backup", filepath.Base(res.SafetyDump))
|
||||
}
|
||||
return msg + " Ennek az alkalmazásnak nincs adatbázisa."
|
||||
return msg + util.Text(lang, "note.restore.no_db_at_all")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -599,7 +596,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// and before v0.226.0 a POST over a failed download was accepted and reported success.
|
||||
if !s.backupMgr.OffboxFullScratchReady(app) {
|
||||
s.logger.Printf("[WARN] [web] off-box place refused for %s: the restore scratch carries no completion marker", app)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
|
||||
return
|
||||
}
|
||||
s.backupMgr.BeginRestoreOp("offbox-place", app)
|
||||
@@ -608,11 +605,11 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.recover_failed", s.noteErr(err)))
|
||||
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é.")
|
||||
s.backupMgr.EndRestoreOp(true, s.note("note.restore.recovered_files", app))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.recover_started", false)
|
||||
}
|
||||
@@ -641,11 +638,11 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_failed", s.noteErr(err)))
|
||||
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é.")
|
||||
s.backupMgr.EndRestoreOp(true, s.note("note.restore.shares_prepared"))
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_restore_started", false)
|
||||
}
|
||||
@@ -668,7 +665,7 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_recover_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
|
||||
|
||||
Reference in New Issue
Block a user