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

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:
2026-09-18 14:19:31 +02:00
parent eb6aa58aa7
commit 48f3336956
142 changed files with 2199 additions and 318 deletions
+50 -50
View File
@@ -1526,9 +1526,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
// preserved leg those are different dates and the run's is the flattering one.
pkgDate, stale := rp.CopyDate, rp.PackagePreserved
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
row.Tier2UnitConfirm = tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
if stale && pkgDate != "" {
row.Tier2UnitStaleNotice = fmt.Sprintf(tier2UnitStaleNoticeFmt, fmtRFC3339Local(pkgDate))
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
}
}
switch cd.LastStatus {
@@ -1662,7 +1662,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.unit_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
@@ -1672,7 +1672,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
@@ -1702,31 +1702,31 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// that have nothing to do with whether the app has a database.
//
// No filesystem path appears in the message, only counts — same rule as reconstituteOutcomeMsg.
func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
if res.VolumesReplayed > 0 || res.DBsReplayed > 0 {
var what string
if res.VolumesReplayed > 0 {
what = fmt.Sprintf("%d adatkötet", res.VolumesReplayed)
what = s.note("note.restore.volumes_count", res.VolumesReplayed)
}
if res.DBsReplayed > 0 {
if what != "" {
what += " és az adatbázis"
what += s.note("note.restore.and_database")
} else {
what = "az adatbázis"
what = s.note("note.restore.the_database")
}
}
return fmt.Sprintf(unitRestoreDataMsgFmt, app, what)
return s.note(unitRestoreDataKey, app, what)
}
// An unknown must never be drawn as a zero. The no-unit fallback cannot report counts, and the
// zero-value shape would otherwise read as „the backup held only settings" over a restore that may
// have replayed the app's whole dataset. Same failure direction as R-88: degrade to UNKNOWN.
if res.CountsUnknown {
return fmt.Sprintf(unitRestoreCountsUnknownMsgFmt, app)
return s.note(unitRestoreCountsUnknownKey, app)
}
if res.ManifestVolumes+res.ManifestDBs > 0 {
return fmt.Sprintf(unitRestoreNoneReturnedMsgFmt, app, res.ManifestVolumes, res.ManifestDBs)
return s.note(unitRestoreNoneReturnedKey, app, res.ManifestVolumes, res.ManifestDBs)
}
return fmt.Sprintf(unitRestoreSettingsOnlyMsgFmt, app)
return s.note(unitRestoreSettingsOnlyKey, app)
}
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
@@ -1735,12 +1735,12 @@ func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
const (
// unitRestoreDataMsgFmt — data really came back. %s app, %s the "N adatkötet[ és az adatbázis]"
// clause built above.
unitRestoreDataMsgFmt = "A(z) %s: %s visszaállítva — az alkalmazás újraindult."
unitRestoreDataKey = "note.unit_restore_data"
// unitRestoreSettingsOnlyMsgFmt — nothing came back and the unit listed nothing. The FIGYELEM
// sentence is a statement about THE BACKUP; it deliberately says nothing about whether the app has
// data of its own, because the manifest cannot answer that (R-355).
unitRestoreSettingsOnlyMsgFmt = "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből."
unitRestoreSettingsOnlyKey = "note.unit_restore_settings_only"
// unitRestoreNoneReturnedMsgFmt — the unit listed data and none of it returned. %d volumes, %d
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
@@ -1748,9 +1748,9 @@ const (
// who believes otherwise will do something worse than waiting.
// unitRestoreCountsUnknownMsgFmt — the no-unit fallback. It claims only what is known: the restore
// ran and the app is back. It deliberately does NOT say data returned and does NOT say it did not.
unitRestoreCountsUnknownMsgFmt = "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid."
unitRestoreCountsUnknownKey = "note.unit_restore_counts_unknown"
unitRestoreNoneReturnedMsgFmt = "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod."
unitRestoreNoneReturnedKey = "note.unit_restore_none_returned"
)
// monitoringIntegritySchedule (R-359) describes what the off-site integrity job actually does.
@@ -1779,13 +1779,13 @@ const (
// no file legs but a full unit mirror sitting in the copy — and it sent those customers to a
// button on another page for data that is now restorable on the page they are already looking at.
// tier2UnitAvailableMsg is that case now.
tier2NoCoverageMsg = "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."
tier2NoCoverageKey = "note.tier2_no_coverage"
// tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable
// recovery unit, so the answer is the action beside this one, not a different page. It names the
// button by its own label and says why the two differ, because the difference is the whole reason
// they are not one button: this one overwrites.
tier2UnitAvailableMsg = "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja."
tier2UnitAvailableKey = "note.tier2_unit_available"
// tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never
// reads as a clean bill of health for data the operation never opened.
@@ -1795,17 +1795,17 @@ const (
// database or the named volumes. Deleting it would let a clean file-restore result read as a clean
// bill of health for data the operation did not look at, which is the sentence it exists to
// prevent.
tier2UnitNotCoveredMsg = "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba."
tier2UnitNotCoveredKey = "note.tier2_unit_not_covered"
// The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was
// just written over the app's live data — an action that overwrites must say what it overwrote
// with, in the sentence the customer is left holding.
tier2UnitRestoreSourceMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s)."
tier2UnitRestoreSourceKey = "note.tier2_unit_restore_source"
// …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run
// too. Where no success has ever been recorded for this app, the date shown is evidence that a
// copy was attempted and nothing more, and the sentence must not present it as evidence of a copy.
tier2UnitRestoreSourceUnprovenMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt)."
tier2UnitRestoreSourceUnprovenKey = "note.tier2_unit_restore_source_unproven"
// R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template.
//
@@ -1822,25 +1822,25 @@ const (
// Contrast — how it differs from the additive button beside it. The register's own requirement:
// a destructive operation reached from a non-destructive surface must carry the
// difference in the confirm, not rely on the customer inferring it from a label.
tier2UnitActionLabel = "Teljes visszaállítás a másolatból"
tier2UnitActionLabelKey = "note.tier2_unit_action_label"
tier2UnitConfirmBase = "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik."
tier2UnitConfirmBaseKey = "note.tier2_unit_confirm_base"
tier2UnitConfirmDateFmt = " A másolat kelte: %s."
tier2UnitConfirmDateFmtKey = "note.tier2_unit_confirm_date_fmt"
tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt."
tier2UnitConfirmDateUnprovenFmtKey = "note.tier2_unit_confirm_date_unproven_fmt"
tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll."
tier2UnitConfirmContrastKey = "note.tier2_unit_confirm_contrast"
// tier2UnitStaleClause (R-403) — the package in this copy is OLDER than the copy's newest run,
// because that run PRESERVED it rather than replacing it with an empty one. Without this the
// confirm would name a date the customer reads as "last night" over a package from before it.
// The whole point of preserving the copy is lost if the surface then misdescribes what it kept.
tier2UnitStaleClause = " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja."
tier2UnitStaleClauseKey = "note.tier2_unit_stale_clause"
// tier2UnitStaleNoticeFmt (R-403) — the same fact on the per-app backup card, where the customer
// looks BEFORE deciding anything. %s is the package's own date.
tier2UnitStaleNoticeFmt = "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
tier2UnitStaleNoticeFmtKey = "note.tier2_unit_stale_notice_fmt"
)
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
@@ -1848,26 +1848,26 @@ const (
//
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
// whole confirm because a date is missing would remove the warning and keep the destruction.
func tier2UnitConfirmMsg(copyDate string, proven bool) string {
return tier2UnitConfirmWithStaleness(copyDate, proven, false)
func (s *Server) tier2UnitConfirmMsg(copyDate string, proven bool) string {
return s.tier2UnitConfirmWithStaleness(copyDate, proven, false)
}
// tier2UnitConfirmWithStaleness is tier2UnitConfirmMsg for a copy whose PACKAGE may be older than its
// newest run (R-403). ONE implementation, two callers — the two-argument form above is the ordinary
// case where the run really did refresh the package.
func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
msg := tier2UnitConfirmBase
func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
msg := s.note(tier2UnitConfirmBaseKey)
if copyDate != "" {
if proven {
msg += fmt.Sprintf(tier2UnitConfirmDateFmt, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
} else {
msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
}
}
msg += tier2UnitConfirmContrast
msg += s.note(tier2UnitConfirmContrastKey)
// R-403 last, so it is the sentence the customer is left holding before they press.
if stale {
msg += tier2UnitStaleClause
msg += s.note(tier2UnitStaleClauseKey)
}
return msg
}
@@ -1876,7 +1876,7 @@ func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) str
// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the
// date it puts in the confirm — so the sentence the customer approves and the sentence they are left
// with cannot name different copies.
func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
// R-403: the OUTCOME names the same date the CONFIRM did — the PACKAGE's, not the copy's newest
// run. Live on demo-hp 2026-08-31 these disagreed by two and a half hours (confirm 11:43, outcome
// 14:23) after a preserved leg, and a customer reading both would not know which restore they had
@@ -1887,9 +1887,9 @@ func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
return ""
}
if proven {
return fmt.Sprintf(tier2UnitRestoreSourceMsgFmt, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
}
return fmt.Sprintf(tier2UnitRestoreSourceUnprovenMsgFmt, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
@@ -1932,9 +1932,9 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// restorable on this one.
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
if covErr == nil && !cov.CanRestore() {
msg := tier2NoCoverageMsg
msg := s.note(tier2NoCoverageKey)
if cov.CanRestoreUnit() {
msg = tier2UnitAvailableMsg
msg = s.note(tier2UnitAvailableKey)
}
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped",
stackName, cov.HasUnit, cov.CanRestoreUnit())
@@ -1951,11 +1951,11 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// apply to this app. Say that, and name the one that does, instead of "sikertelen".
if errors.Is(err, backup.ErrTier2NoRestorableData) {
s.logger.Printf("[WARN] [web] Tier-2 file restore not applicable: stack=%s", stackName)
s.backupMgr.EndRestoreOp(false, tier2NoCoverageMsg)
s.backupMgr.EndRestoreOp(false, s.note(tier2NoCoverageKey))
return
}
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.file_failed", s.noteErr(err)))
return
}
// C9-F1 (the quiet half): even where the restore DOES cover something it covers only the
@@ -1963,12 +1963,12 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// copy's recovery-unit/. „minden fájl megvan a helyén" was a blanket claim over data that was
// never opened; immich's 1.3 GB Postgres unit is the case that makes it dangerous. Claim only
// what was EXAMINED, and disclose the rest.
msg := "Minden vizsgált fájl megvan a helyén."
msg := s.note("note.restore.all_files_present")
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
msg = s.note("note.restore.files_from_second", stackName, n)
}
if cov.HasUnit {
msg += " " + tier2UnitNotCoveredMsg
msg += " " + s.note(tier2UnitNotCoveredKey)
}
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files, legs=%v)", stackName, n, cov.Legs)
s.backupMgr.EndRestoreOp(true, msg)
@@ -2024,7 +2024,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
}
if !cov.CanRestoreUnit() {
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", tier2NoCoverageKey), http.StatusFound)
return
}
@@ -2035,7 +2035,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
res, err := s.backupMgr.RestoreTier2Unit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Teljes visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
@@ -2045,8 +2045,8 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
// second thing to keep honest. What IS added is which copy it came from and how old that copy
// is: this action overwrote the customer's live data, and the sentence they are left with has
// to say what it overwrote it with (Scenario E).
msg := unitRestoreOutcomeMsg(stackName, res)
if src := tier2UnitSourceMsg(cov); src != "" {
msg := s.unitRestoreOutcomeMsg(stackName, res)
if src := s.tier2UnitSourceMsg(cov); src != "" {
msg += " " + src
}
s.backupMgr.EndRestoreOp(true, msg)