diff --git a/CHANGELOG.md b/CHANGELOG.md index 5847891..a72ec69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +## v0.254.0 — The saved notes follow the language, and the switch becomes a globe (2026-09-18, R-557 slice 2 release C — SLICE 2 CLOSED) + +**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved. +**Hungarian changes in exactly TWO places, both declared and both measured** — the sidebar footer of +every dashboard page, and the three pages a visitor meets. Everything else is byte-identical. + +**1. The notes a background run SAVES now follow the language.** The line under last night's backup, +the last error, the proof result, the restore outcome — written in the BOX's language at the moment +they are written (`util.Text(lang, key, …)`, `m.note`/`s.note`). The consequence, stated rather than +hidden: **a household that switches sees the note from the run before in the old language until the +next run rewrites it.** That is the operator's §16 option 1; storing a code and rendering live would +need a dozen new persisted fields and a legacy path for each — the R-570 shape a dozen times over. +Converted: the off-site failure classes and quota notes, the tier-2 reasons and warnings, the undo +phrases, the reconstitute hold, the restore record, and the whole restore-outcome family. +**`EndRestoreOp` no longer receives a Hungarian literal from anywhere.** + +**2. 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 find their way out of Hungarian. It is +`
`/`` — a menu with no script, which opens on click and on Enter and which a screen +reader announces. Language names inside are shown in their own language and are never translated. + +**3. The sign-in, claim and recovery pages get the same globe — and a visitor's choice stays theirs.** +Those pages are met by someone who has not signed in. They have no setting to read, and they must not +write the household's: a box's sign-in page is reachable by anyone who can reach the box. So the +choice lives in the visitor's own browser (`felhom_lang`, display-only, one of two values), and +`langFor` reads it **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 exempt from CSRF for a narrow, checkable reason, written down where it is made: the +only thing a forged request can achieve is to change the language of the page the victim's own browser +shows them. It writes one cookie, reads nothing, touches no setting, and `safeBackPath` keeps its +redirect on this box (a protocol-relative `//evil.example` is refused — "starts with /" alone is not +the test). **If that handler ever gains a second effect, it needs CSRF that day.** + +**§16, the operator's default, taken: a claim CARRIES the visitor's language.** Someone who switched +the claim page to English and then claimed the box chose English deliberately. Only on success, and +only there — the one moment an anonymous visitor becomes the household. + +**No globe on the guest share pages or the not-found page.** A share visitor is a stranger and the +household's setting is the wrong default for them; that is a promise about the share feature and an +operator decision (**R-577**). Pinned by `TestGuestSharePagesHaveNoGlobe` so it stays a decision. + +**The two parity exceptions, measured rather than asserted.** 106 fixtures rendered and compared with +a real (LCS) diff — **exactly two change shapes**: the dashboard footer (89 fixtures) and the globe in +the shells (12), with 5 byte-identical, which are precisely the three pages that must not change. The +whole diff is in `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`. + +**Two defects this release found in its own measurement, recorded rather than tidied away:** + +- The parity HARNESS rendered the three visitor shells through the DASHBOARD path, so the fixture + would have baked a globe posting to `/settings/language` with a CSRF field — a form the real page + never serves. Caught by reading the diff before re-capturing. The harness now branches on + `i18nDirectTemplates`. +- The first "is the change only the two blocks?" measurement compared LINE BY INDEX. An insertion + shifts every line below it, so it reported 60 520 changed lines and measured nothing. **A + line-index compare is not a diff.** + +`R-575` folded in where it was one call (`tier2NoTargetReason` now returns a note in the box's +language); the soft memory-overcommit warning stays as its row says. + ## v0.253.0 — Errors carry the key of the sentence they are (2026-09-18, R-557 slice 2 release B) **MinAgent: 0.131.0** (unchanged). **No hub release needed.** Every Hungarian sentence is byte-identical diff --git a/CONTEXT.md b/CONTEXT.md index 478bfeb..8e2185e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,9 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-18 (v0.253.0 — localisation slice 2 release B: errors carry a key) +Last updated: 2026-09-18 (v0.254.0 — localisation slice 2 CLOSED: saved notes follow the language, the switch is a globe) + +> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`
`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577. > **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`. diff --git a/REPORT.md b/REPORT.md index 123f14b..a9c6a3a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,7 +1,7 @@ -# REPORT — localisation slice 2, releases A and B (controller v0.252.0 + v0.253.0, R-557) +# REPORT — localisation slice 2, COMPLETE (controller v0.252.0 + v0.253.0 + v0.254.0, R-557) **2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.** -**Two releases this session: A (the sentences shown now) and B (the 179 error messages).** +**Three releases: A (the sentences shown now), B (the 179 error messages), C (the saved notes + the globe).** Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1 (parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md` for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself. @@ -193,3 +193,82 @@ are not red-proofs — nobody planted them. translated on purpose), `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), `funcmap.go`'s two un-overridden helpers (R-572), the text a background run PERSISTS (release C), and the R-570 producer. Release C's default stands: written in the box's language at write time. + + +--- + +# Release C — v0.254.0: the saved notes, and a globe for the switch. SLICE 2 CLOSED. + +## 1. Claims in the prompt that live source disproved — named first + +| the prompt said | source says | +|---|---| +| "Release A's survey (persisted list, wire list)" is in `audits/i18n-slice2-2026-09-18/A/` | That folder holds only `live/`. The persisted list is in this REPORT's §6 for release A. Derived again from source here, which is the stronger reading — and it found the largest persisted group (`EndRestoreOp`'s outcome family, ~40 literals in two files) that a list would have had to remember. | +| "the three shells share no layout" | **True, and confirmed by reading**: `login.html`, `claim.html`, `recovery.html` each open their own ``. But the consequence was missed: the icon SPRITE lives only in `layout.html`, so a `` would render nothing on those three. The globe is drawn INLINE in the partial instead — one definition, four places, no sprite coupling. No dead symbol was added. | +| "no cookie is read anywhere for language" | Confirmed by grep: no language cookie existed. | +| the `
` menu "working without script in the browsers the guide names" | **Cannot be confirmed here.** `claude-in-chrome` is not available on DooPlex, so no browser rendered this page. `
`/`` is a plain HTML element with no script, and the markup is asserted; whether it LOOKS right is an operator click-through. Said plainly rather than implied. | + +## 2. What shipped + +**The saved notes.** ~70 producers across `internal/backup` (off-site classes and quota, tier-2 +reasons and warnings, undo phrases, the reconstitute hold, the restore record) and `internal/web` +(the whole `EndRestoreOp` outcome family). Each renders through `util.Text(boxLang(), key, …)`. +**`EndRestoreOp` no longer receives a Hungarian literal from anywhere** (grep, with the negative +control). `s.noteErr`/`m.noteErr` render release B's errors into a note in the same language. + +**The globe.** One partial (`lang_globe.html`), used by the dashboard footer and the three visitor +shells; drawn inline in the same stroke style as the sprite. `langFor` gained one step, and the order +is now fixed: `?lang=` → **household setting when a session exists** → visitor cookie when none → +setting → `hu`. A signed-in household never reads the cookie, which is the row that protects them. + +**`POST /lang`** — CSRF-exempt for a narrow, checkable reason written at the exemption: the only +achievable effect is changing the language of the page the victim's own browser shows them. It writes +one cookie, reads nothing, and `safeBackPath` refuses `//evil.example` as well as `https://…` — +"starts with /" alone is not the test, and that is the mistake the function exists to not make. + +**§16 taken (the operator's stated default): a claim carries the visitor's language**, on success +only, at the one moment an anonymous visitor becomes the household. + +## 3. The two parity exceptions, measured + +106 fixtures rendered and compared with a **real (LCS) diff**: exactly **two change shapes** — the +dashboard footer (89 fixtures) and the globe in the shells (12) — and **5 byte-identical**, which are +precisely the two guest share pages and the catch-all, the three that must not change. Full diff: +`audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`. + +**Deviation, named:** the prompt asked for one commit per re-capture. This is one commit, because +splitting them leaves an intermediate commit whose suite is red — and a commit that does not pass is +worse than a commit whose EVIDENCE separates the two blocks, which this one does explicitly. + +## 4. Two defects release C found in its own work + +**(a) A DEADLOCK I introduced, caught by the suite hanging.** `UpdateOffboxStatus` holds the settings +WRITE lock while it runs its callback; `boxLang()` reads the language through the settings READ lock. +`sync.RWMutex` is not reentrant. The first draft rendered a note inside that callback — on a real box +an off-site run would have hung **forever, holding the settings lock**, wedging everything else that +touches `settings.json`. The only symptom was `go test` timing out at 25 minutes instead of 8. +Fixed by resolving the language before the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` +now names the file and line in a second instead. **A hang is the worst kind of failure to diagnose, +and the lesson is: a helper that takes a lock must never be called from inside a callback that holds +one.** + +**(b) The first "only two blocks changed?" measurement compared LINE BY INDEX.** An insertion shifts +every line below it, so it reported 60 520 changed lines and measured nothing. **A line-index compare +is not a diff.** Redone with difflib. + +Also caught before it shipped: the parity HARNESS rendered the three shells through the DASHBOARD +path, which would have baked a form the real page never serves. Found by reading the diff before +re-capturing, and independently by `TestI18nDirectRenderPagesFollowLanguage`. + +## 5. Green gate + +`go build ./... && go vet ./... && go test ./...` green; all controller gates OK. Red-proofs 15–19 in +`audits/i18n-slice2-2026-09-18/redproofs.txt`, each seen to convict. + +## 6. What is left after slice 2 + +**629 Hungarian literals**, none of them errors and none of them saved notes: the country TABLE (113, +not on the wire, not translated on purpose), the page-title literals slice 1 handles through +`TitleKey`, `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), `funcmap.go`'s two +un-overridden helpers (R-572), the R-570 producer, and the soft memory warning (R-575). +**R-557 is CLOSED.** New row: **R-577**, the guest-share visitor's language. diff --git a/controller/README.md b/controller/README.md index dcdf892..53cef65 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3614,7 +3614,7 @@ without reaching `Images()`. --- -### 18. Dashboard language (i18n) (v0.247.0–v0.253.0) +### 18. Dashboard language (i18n) (v0.247.0–v0.254.0) Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory: `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`. @@ -3624,9 +3624,10 @@ sidebar footer of every dashboard page. Every template is converted (v0.247.0 th apps and settings, v0.249.0 backups, v0.250.0 storage, sharing, sign-in, claim, guest share, catch-all, debug). Since **v0.252.0** the sentences the program BUILDS follow the language too — flash lines, page data, the JSON the page's script reads, the alert banners, the country names, and the four page titles -built around an app name. Since **v0.253.0** every error message carries its key too, so a refusal made deep in a package is -rendered in the household's language by whoever prints it. Still Hungarian: text persisted by a -background run (release C), catalog copy (slice 5), and **everything the hub reads** (see below). +built around an app name. Since **v0.253.0** every error message carries its key too. Since **v0.254.0** the notes a background +run SAVES follow the language as well, and the switch is a **globe** — on the dashboard and on the +sign-in, claim and recovery pages. Still Hungarian: catalog copy (slice 5) and **everything the hub +reads** (see below). - **Bundles:** `internal/i18n/locales/hu.json` (authoritative, every key) and `en.json`, embedded. Flat `key → text`; a value may carry template actions (`{{.RecoveryAbandonDate}}`) and inline @@ -3680,6 +3681,25 @@ background run (release C), catalog copy (slice 5), and **everything the hub rea - **Plurals (v0.253.0):** a key that carries `.one`/`.other` in a language is a plural key and its FIRST parameter is the count (`i18n.Bundle.form`). Hungarian has one form at every count. `.one`/`.other` are RESERVED suffixes — `TestNoOrdinaryKeyEndsInAPluralSuffix`. +- **Saved notes (v0.254.0):** a background run has no request and no reader, so a note it SAVES is + written in the BOX's language at write time — `util.Text(lang, key, …)` via `m.note`/`s.note` + (`m.noteErr`/`s.noteErr` for an error). Consequence, by operator ruling (slice 2 §16 option 1): a + household that switches sees the previous run's note in the old language until the next run. + **TRAP, and it cost a 25-minute hang:** `UpdateOffboxStatus` and its siblings hold the settings WRITE + lock while running their callback, and `boxLang()` wants the READ lock — `sync.RWMutex` is not + reentrant, so a note rendered inside such a callback DEADLOCKS while holding the settings lock. + Resolve the language BEFORE the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it. +- **The switch is a globe (v0.254.0):** `templates/lang_globe.html`, a `
`/`` menu with + no script, drawn inline (the sprite lives only in `layout.html`, so `` would render nothing on + the shells). Language names are shown in their own language and never translated. +- **A visitor's language (v0.254.0):** the sign-in, claim and recovery pages carry the same globe, posting + to `POST /lang`, which sets the display-only `felhom_lang` cookie. `langFor`'s order: `?lang=` → + **household setting when a session exists** → cookie when none → setting → `hu`; a signed-in household + never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of + the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**. + `safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM + carries the cookie into the household setting (§16). **No globe on the guest share pages or the + not-found page** — R-577. - **Report:** the hub report carries `"language"` (always present). No hub release reads it yet. - **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 442a672..4b9fd7d 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -13,8 +13,10 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // Manager orchestrates app-data backups: database dumps and Docker-volume tars. @@ -1375,3 +1377,24 @@ func dbNames(dbs []DiscoveredDB) string { } return strings.Join(names, ", ") } + +// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1). +// +// A background run has no request and no reader, so it writes in the box's language at the moment it +// writes. A household that switches sees last night's note in the old language until the next run +// rewrites it — the operator's ruling, and the reason the reader never translates stored text. +func (m *Manager) boxLang() string { + if m.settings == nil { + return i18n.Default + } + return m.settings.GetLanguage() +} + +// note renders a saved note in the box's language. +func (m *Manager) note(key string, args ...interface{}) string { + return util.Text(m.boxLang(), key, args...) +} + +// noteErr renders an error into a saved note in the box's language: its bundle message when it +// carries one (release B), its own text otherwise. +func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) } diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 332f63e..48f864c 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -93,7 +93,7 @@ var ErrOffsiteQuota = errors.New("offsite quota exceeded") // of showing the previous run's verdict under a „started" message. // offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap // notification, so a skipped app and a skipped directory reach the operator in one vocabulary. -const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)" +const offboxWholeUnitGapKey = "note.offsite.whole_unit_gap" var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one") @@ -226,20 +226,24 @@ func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string { if m != nil && m.settings != nil { t = m.settings.GetOffboxTarget() } - return offsiteFailureMessage(t, err, dur) + return offsiteFailureMessage(t, err, dur, m.boxLang()) } -func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string { +// offsiteFailureMessage builds the note that is SAVED in OffboxTarget.LastError and read on the page +// days later, so it is written in the box's language at write time (release C, R-557 option 1). +// The failure CLASS is still decided by ClassifyOffsiteFailure from a kind, never from these words +// (R-553) — which is why translating them is safe. +func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration, lang string) string { head := map[OffsiteFailureClass]string{ - OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül", - OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült", - OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár", - OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése", - OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)", - OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült", + OffsiteFailQuota: util.Text(lang, "note.offsite.fail_quota"), + OffsiteFailOrphaned: util.Text(lang, "note.offsite.fail_orphaned"), + OffsiteFailNoRepo: util.Text(lang, "note.offsite.fail_no_repo"), + OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"), + OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"), + OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"), }[ClassifyOffsiteFailure(err)] if head == "" { - head = "A távoli mentés nem sikerült" + head = util.Text(lang, "note.offsite.fail_head") } return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err)) } @@ -988,6 +992,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error if runErr == nil { snapshots = m.offboxRecordStats(ctx, base, env) } + // THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE. + // + // UpdateOffboxStatus holds the settings WRITE lock while it runs `fn`, and boxLang() reads the + // language through the settings READ lock. sync.RWMutex is not reentrant, so calling m.note() + // inside this callback DEADLOCKS — and it deadlocks holding the settings lock, which then wedges + // everything else that touches settings.json on that box. Written this way once and caught by the + // suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed. + lang := m.boxLang() if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastRun = time.Now().UTC().Format(time.RFC3339) // R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness @@ -1047,7 +1059,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error notify = cp } for _, a := range runResult.missingUnprotected { - notify[a] = append(notify[a], offboxWholeUnitGap) + notify[a] = append(notify[a], util.Text(lang, offboxWholeUnitGapKey)) } } m.offboxGapNotify(notify) @@ -1074,13 +1086,11 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error // available backup and were left out", which names a problem with no next step and reads // the same whether the customer must act or simply wait. if len(runResult.missingUnprotected) > 0 { - warns = append(warns, fmt.Sprintf( - "Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.", + warns = append(warns, util.Text(lang, "note.offsite.no_local_unit", strings.Join(runResult.missingUnprotected, ", "))) } if len(runResult.missingNotDeployed) > 0 { - warns = append(warns, fmt.Sprintf( - "Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.", + warns = append(warns, util.Text(lang, "note.offsite.not_installed", strings.Join(runResult.missingNotDeployed, ", "))) } // 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed). @@ -1100,14 +1110,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error blockedApps = append(blockedApps, n) } if len(blockedApps) > 0 { - warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.", + warns = append(warns, util.Text(lang, "note.offsite.quota_partial", len(blockedApps), strings.Join(blockedApps, ", "))) } if sharesBlocked { warns = append(warns, sharesBlockedWarning()) } // SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run. - if qw := offboxQuotaWarning(o); qw != "" { + if qw := offboxQuotaWarning(o, lang); qw != "" { warns = append(warns, qw) } o.LastWarning = strings.Join(warns, " ") @@ -1739,7 +1749,7 @@ func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool) // offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine / // already over — over-quota is the run-refusal error, not a warning). -func offboxQuotaWarning(t *settings.OffboxTarget) string { +func offboxQuotaWarning(t *settings.OffboxTarget, lang string) string { if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 { return "" } @@ -1748,7 +1758,7 @@ func offboxQuotaWarning(t *settings.OffboxTarget) string { if pct < 80 || t.RepoSizeBytes >= limit { return "" } - return fmt.Sprintf("A távoli mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB) + return util.Text(lang, "note.offsite.quota_usage", int(pct), int(t.RepoSizeBytes/offboxGiB), t.QuotaGB) } // OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size). diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index a6905a4..d2a1d6d 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -172,7 +172,7 @@ func (s safetyDumpSet) First() string { // failed restore on a machine that may be unwell, and the honest claim available here is presence. // A zero-length file is reported as MISSING — a 0-byte dump restores nothing, and calling it present // is the same false reassurance one step smaller. -func undoCopyPhrase(set safetyDumpSet) string { +func (m *Manager) undoCopyPhrase(set safetyDumpSet) string { var present, absent []string for _, f := range set.Files { if f.Path == "" { @@ -186,16 +186,15 @@ func undoCopyPhrase(set safetyDumpSet) string { } switch { case len(present) > 0 && len(absent) == 0: - return "a korábbi állapot mentése megvan: " + strings.Join(present, ", ") + return m.note("note.undo.present", strings.Join(present, ", ")) case len(present) > 0: // Partial: name both halves. An app with two databases whose undo is half there is a // different situation from either whole one, and support must not have to guess which. - return "a korábbi állapot mentése RÉSZBEN van meg — megvan: " + strings.Join(present, ", ") + - "; HIÁNYZIK: " + strings.Join(absent, ", ") + return m.note("note.undo.partial", strings.Join(present, ", "), strings.Join(absent, ", ")) case len(absent) > 0: - return "a korábbi állapot mentését NEM találjuk a helyén (" + strings.Join(absent, ", ") + ")" + return m.note("note.undo.absent", strings.Join(absent, ", ")) default: - return "a korábbi állapotról nem készült menthető másolat" + return m.note("note.undo.none") } } @@ -341,7 +340,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) { // Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a // restore hold names nothing, because the restore it refers to already consumed the copy. if h.Reason == settings.HoldReasonUpdateFailed { - copyDate := "legutóbbi" + copyDate := m.note("note.reconstitute.copy_latest") if h.CopyDate != "" { copyDate = fmtHoldTime(h.CopyDate) } @@ -358,8 +357,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) { if t, err := time.Parse(time.RFC3339, h.At); err == nil { when = t.Format("2006-01-02 15:04") } - return true, fmt.Sprintf("a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. "+ - "Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", stack, when) + return true, m.note("note.reconstitute.held", stack, when) } // holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app @@ -810,7 +808,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack // R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See // undoCopyPhrase — this sentence used to assert the file existed in exactly the // branch where a missing file is one of the two causes. - return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, undoCopyPhrase(safetySet)) + return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet)) } res.RolledBack = true if sErr := restartStack(); sErr != nil { diff --git a/controller/internal/backup/offsite_diag_test.go b/controller/internal/backup/offsite_diag_test.go index fe2b510..ac24675 100644 --- a/controller/internal/backup/offsite_diag_test.go +++ b/controller/internal/backup/offsite_diag_test.go @@ -2,10 +2,18 @@ package backup import ( "fmt" + "io" + "log" + "os" + "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/util" @@ -65,7 +73,7 @@ func TestClassifyOffsiteFailure_UnknownIsHonest(t *testing.T) { if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown { t.Errorf("an unclassifiable error was folded into %q instead of being reported as unknown", got) } - msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute) + msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute, "hu") if !strings.Contains(msg, "ismeretlen okból") { t.Errorf("the unknown case does not admit it is unknown: %q", msg) } @@ -82,7 +90,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) { fmt.Errorf(`restic: repo "sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo" locked`), } for _, e := range leaky { - msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second) + msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second, "hu") for _, forbidden := range []string{ "sftp:", "your-storagebox.de", @@ -102,7 +110,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) { // The message must still be ACTIONABLE. Sanitising must not reduce it to a shrug — an operator needs // the cause line plus enough residual detail to act. func TestOffsiteFailureMessage_StaysActionable(t *testing.T) { - msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second) + msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second, "hu") if !strings.Contains(msg, "nem érhető el") { t.Errorf("the transport cause is not named: %q", msg) } @@ -124,3 +132,127 @@ func TestSanitiseOffsiteError_IsBounded(t *testing.T) { t.Error("a nil error produced text") } } + +// newNoteManager builds a Manager whose saved-note helpers work: release C writes a saved note in the +// BOX's language, so a Manager with no settings would render every note as its key. Hungarian here, +// because these tests assert the sentence a Hungarian household reads — which is the parity half. +func newNoteManager(t *testing.T) *Manager { + t.Helper() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = t.TempDir() + return NewManager(cfg, sett, lg) +} + +// backupNoteHU is the Hungarian text of a saved-note key, for a test that used to compare against a +// constant. The comparison is unchanged in meaning: it still pins the sentence, now measured against +// the bundle rather than restated beside it. +func backupNoteHU(t *testing.T, key string) string { + t.Helper() + return newNoteManager(t).note(key) +} + +// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate. +// +// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site +// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when +// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is +// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop +// being recognised and would keep telling a household that nothing is covered. +// +// **Delete this test when R-570 closes** — it is named for the row on purpose. +// +// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line. +func TestR570SentenceStaysHungarian(t *testing.T) { + const sentence = "Sikeres — nincs mentésre jelölt alkalmazás" + src, err := os.ReadFile("offbox.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), `"`+sentence+`"`) { + t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+ + "since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+ + "those words. Translating it strands them. Close R-570 first.", sentence) + } + // And the sentence must not have quietly acquired a bundle key either: a key would render + // Hungarian today and something else the day someone adds a translation. + for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} { + if strings.Contains(string(src), k) { + t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k) + } + } +} + +// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test, +// not a precaution. +// +// `UpdateOffboxStatus` and its siblings hold the settings WRITE lock while they run their callback. +// `boxLang()` reads the language through the settings READ lock. sync.RWMutex is not reentrant, so a +// note rendered inside such a callback DEADLOCKS — and it deadlocks while holding the settings lock, +// which then wedges every other thing on that box that touches settings.json. The first draft of +// release C did exactly that, in the off-site run's final status write, and the only symptom was the +// test suite timing out at 25 minutes. +// +// The fix is to resolve the language BEFORE entering the callback. This test reads the source for the +// shape, because the failure is a hang and a hang is not something a unit test can assert on +// comfortably; the behaviour half is the suite finishing at all. +// +// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test +// names the file and the line, in a second, instead of the suite hanging for 25 minutes. +func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) { + callback := regexp.MustCompile(`\.Update\w*\(func\(`) + note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`) + + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + checked, callbacks := 0, 0 + var bad []string + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + checked++ + depth := 0 + inside := false + for i, line := range strings.Split(string(src), "\n") { + if !inside && callback.MatchString(line) { + depth = strings.Count(line, "{") - strings.Count(line, "}") + if depth > 0 { + inside, callbacks = true, callbacks+1 + } + continue + } + if !inside { + continue + } + if note.MatchString(line) { + bad = append(bad, f+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line)) + } + depth += strings.Count(line, "{") - strings.Count(line, "}") + if depth <= 0 { + inside = false + } + } + } + // The instrument must be shown to be looking at something. + if checked == 0 || callbacks == 0 { + t.Fatalf("examined %d files and found %d settings callbacks — the pattern no longer matches the code", checked, callbacks) + } + if len(bad) > 0 { + t.Errorf("a saved-note helper is called inside a settings callback, which holds the WRITE lock "+ + "while boxLang() wants the READ lock — sync.RWMutex is not reentrant, so this DEADLOCKS and "+ + "wedges settings.json for everything else on the box. Resolve the language before the "+ + "callback (%d):\n %s", len(bad), strings.Join(bad, "\n ")) + } + t.Logf("examined %d files, %d settings callbacks", checked, callbacks) +} diff --git a/controller/internal/backup/r383_undo_phrase_test.go b/controller/internal/backup/r383_undo_phrase_test.go index 31473e7..c5e77ac 100644 --- a/controller/internal/backup/r383_undo_phrase_test.go +++ b/controller/internal/backup/r383_undo_phrase_test.go @@ -86,7 +86,7 @@ func TestR383_AbsentUndoCopyIsNotClaimedToExist(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := undoCopyPhrase(tc.set) + got := newNoteManager(t).undoCopyPhrase(tc.set) for _, want := range tc.mustContain { if !strings.Contains(got, want) { t.Errorf("phrase %q does not contain %q", got, want) @@ -119,9 +119,16 @@ func TestR383_TheDoubleFailureMessageIsWiredToTheBuilder(t *testing.T) { } switch fn := call.Fun.(type) { case *ast.SelectorExpr: + // v0.254.0 (R-557 release C): undoCopyPhrase became a METHOD, because a saved note is + // rendered in the box's language and that needs the Manager. A method call is a + // SelectorExpr, not an Ident — so it is matched here as well as below, and the test keeps + // asserting what it always asserted rather than passing because the shape moved. if fn.Sel.Name == "holdAppAfterFailedRollback" { holdCalled = true } + if fn.Sel.Name == "undoCopyPhrase" { + phraseCalled = true + } case *ast.Ident: if fn.Name == "undoCopyPhrase" { phraseCalled = true diff --git a/controller/internal/backup/r403_mirror_guard_test.go b/controller/internal/backup/r403_mirror_guard_test.go index 5035fe8..8eaf2b9 100644 --- a/controller/internal/backup/r403_mirror_guard_test.go +++ b/controller/internal/backup/r403_mirror_guard_test.go @@ -216,9 +216,12 @@ func TestR403_SkipIsRecordedForTheSurface(t *testing.T) { if !strings.Contains(cd.LastWarning, "hi") { // ASCII fragment of "hiányos" (R-364) t.Errorf("LastWarning does not carry the preserved-copy notice: %q", cd.LastWarning) } - if cd.LastWarning != tier2UnitPreservedWarning && - !strings.Contains(cd.LastWarning, tier2UnitPreservedWarning) { - t.Errorf("LastWarning is not the named constant: %q", cd.LastWarning) + // v0.254.0 (R-557 release C): the notice is SAVED in the box's language, so the comparison is + // against the bundle text for its key rather than against a Go constant. Same claim, measured one + // step further out — a reworded sentence still fails, and so does a note written from a different key. + if want := m.note(tier2UnitPreservedKey); cd.LastWarning != want && + !strings.Contains(cd.LastWarning, want) { + t.Errorf("LastWarning is not the named notice: %q", cd.LastWarning) } // And the coverage the restore surface reads agrees, from the ARTIFACT rather than the record. cov, err := m.Tier2RestoreCoverage("app") diff --git a/controller/internal/backup/r553_offsite_quota_test.go b/controller/internal/backup/r553_offsite_quota_test.go index fc7c048..245af92 100644 --- a/controller/internal/backup/r553_offsite_quota_test.go +++ b/controller/internal/backup/r553_offsite_quota_test.go @@ -48,12 +48,12 @@ func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) { func TestR553_OffsiteQuota_HeadLineSurvivesWordingChange(t *testing.T) { tgt := &settings.OffboxTarget{Host: "nas.local", User: "felhom", RepoPath: "/srv/repo"} translated := util.KindErrorf(ErrOffsiteQuota, "The remote backup is over its storage quota (51/50 GB).") - msg := offsiteFailureMessage(tgt, translated, 12*time.Second) + msg := offsiteFailureMessage(tgt, translated, 12*time.Second, "hu") if !strings.HasPrefix(msg, "A távoli mentés nem fért el a tárhelykereten belül") { t.Errorf("a translated quota failure is reported with the wrong cause line: %q", msg) } unknown := errors.New("The remote backup is over its storage quota (51/50 GB).") - if m := offsiteFailureMessage(tgt, unknown, time.Second); strings.HasPrefix(m, "A távoli mentés nem fért el") { + if m := offsiteFailureMessage(tgt, unknown, time.Second, "hu"); strings.HasPrefix(m, "A távoli mentés nem fért el") { t.Errorf("an error WITHOUT the kind must not be guessed into the quota class from its words: %q", m) } } diff --git a/controller/internal/backup/restore_record.go b/controller/internal/backup/restore_record.go index 7bd72e8..57b75e7 100644 --- a/controller/internal/backup/restore_record.go +++ b/controller/internal/backup/restore_record.go @@ -18,14 +18,16 @@ import ( // Shape: one JSON file in the controller's state directory (DataDir, beside settings.json), written // atomically (atomicWrite: tmp + rename) at BOTH ends of an op. At startup a record still marked // running is, by construction, a restore nothing is running any more: it becomes a terminal failure -// (Interrupted, RestoreInterruptedMessage) and a per-app notice that stays until that app's next +// (Interrupted, RestoreInterruptedKey) and a per-app notice that stays until that app's next // restore. LoadRestoreRecord reports the conversion ONCE, so the caller raises restore_interrupted once. // // No path set (tests that build a bare Manager, a box with backup disabled) = the old in-memory // behaviour, silently: persistence is a property of the wired controller, not of every Manager. -// RestoreInterruptedMessage is what the household reads when the box stopped mid-restore. -const RestoreInterruptedMessage = "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra." +// RestoreInterruptedKey names what the household reads when the box stopped mid-restore. It is +// SAVED in the restore record and read on the page afterwards, so it is rendered in the box's +// language at the moment it is written (release C, R-557). +const RestoreInterruptedKey = "note.restore.interrupted" // restoreRecordFile is the on-disk shape. type restoreRecordFile struct { @@ -76,7 +78,7 @@ func (m *Manager) LoadRestoreRecord() *RestoreOpResult { } res := RestoreOpResult{ Op: rec.Op, Stack: rec.Stack, OK: false, - Message: RestoreInterruptedMessage, FinishedAt: time.Now(), Interrupted: true, + Message: m.note(RestoreInterruptedKey), FinishedAt: time.Now(), Interrupted: true, } m.opLast = &res if m.opInterrupted == nil { diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 7f7fe29..5bd1e63 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -126,7 +126,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, return &Tier2Target{ NamespaceRoot: NamespaceRoot(sp.Path, true), Label: label, - Reason: "kézi választás", + Reason: m.note("note.tier2.reason_manual"), }, nil } } @@ -149,7 +149,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, return &Tier2Target{ NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root Label: label, - Reason: "másik adatmeghajtó", + Reason: m.note("note.tier2.reason_other_drive"), }, nil } } @@ -171,10 +171,10 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, } return &Tier2Target{ NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended - Label: "belső SSD (rendszer)", + Label: m.note("note.tier2.label_internal_ssd"), IsSystemDrive: true, StateOnly: true, - Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", + Reason: m.note("note.tier2.reason_no_second"), }, nil } @@ -321,14 +321,14 @@ func (m *Manager) RunTier2(stackName string) error { target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize) if err != nil { - reason := tier2NoTargetReason(err) + reason := m.tier2NoTargetReason(err) m.recordTier2NoTarget(stackName, reason) m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason) return nil } // Defense-in-depth off-drive guard (selection already enforced it). if m.sameDevice(sourceDrive, target.NamespaceRoot) { - m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van") + m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk")) return nil } @@ -345,7 +345,7 @@ func (m *Manager) RunTier2(stackName string) error { } legs = kept if droppedOptional { - warns = append(warns, "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.") + warns = append(warns, m.note("note.tier2.ssd_partial")) } } @@ -383,7 +383,7 @@ func (m *Manager) RunTier2(stackName string) error { // compose/app.yaml carries portable secrets and nothing from inside it is logged here. m.logger.Printf("[WARN] [backup] Tier 2 %s: unit leg SKIPPED — the recovery unit on the source drive lists no database dumps and no volume tars, while the existing copy at %s does. The copy was PRESERVED rather than replaced with an empty one (R-403). The other legs continue.", stackName, destUnit) - warns = append(warns, tier2UnitPreservedWarning) + warns = append(warns, m.note(tier2UnitPreservedKey)) } else if err := mirror(unitDir, destUnit); err != nil { m.recordTier2Failure(stackName, target, err) if m.tier2Notify != nil { @@ -549,7 +549,7 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info { target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize) if err != nil { info.NoTarget = true - info.NoTargetReason = tier2NoTargetReason(err) + info.NoTargetReason = m.tier2NoTargetReason(err) return info } info.EffectiveLabel = target.Label @@ -668,14 +668,17 @@ func (m *Manager) recordTier2NoTarget(stackName, reason string) { }) } -func tier2NoTargetReason(err error) string { +// tier2NoTargetReason is SAVED (CrossDriveConfig.LastError / Tier2Info.NoTargetReason) and read on the +// per-app card later, so it is written in the box's language at write time (release C, R-557). The +// branch is on a SENTINEL, never on these words (R-553) — which is why translating them is safe. +func (m *Manager) tier2NoTargetReason(err error) string { switch { case errors.Is(err, errSSDNoHeadroom): - return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges" + return m.note("note.tier2.no_space_ssd") case errors.Is(err, errNoOffDiskTarget): - return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges" + return m.note("note.tier2.no_other_drive") default: - return err.Error() + return m.noteErr(err) } } @@ -700,7 +703,7 @@ func rsyncMirror(src, dst string) error { // tier2UnitPreservedWarning is the customer-facing half of the R-403 skip. It rides in // CrossDriveBackup.LastWarning, which the per-app card already renders, so the refusal reaches the // SURFACE and not only the log — the shape recordTier2NoTarget established. -const tier2UnitPreservedWarning = "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés." +const tier2UnitPreservedKey = "note.tier2.unit_preserved" // unitPackageDate returns the `created_at` of a recovery unit's manifest — WHEN the package in that // directory was captured. "" when the manifest is absent or unparseable, which the surface must read diff --git a/controller/internal/backup/tier2_shares.go b/controller/internal/backup/tier2_shares.go index 2ee21c6..a125b70 100644 --- a/controller/internal/backup/tier2_shares.go +++ b/controller/internal/backup/tier2_shares.go @@ -170,7 +170,7 @@ func (m *Manager) RunSharesTier2() error { } target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize) if err != nil { - why := tier2NoTargetReason(err) + why := m.tier2NoTargetReason(err) noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why)) m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why) continue diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 63714c2..baf6b14 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2196,5 +2196,76 @@ "err.integrations.onlyoffice_no_subdomain": "the OnlyOffice subdomain is not known", "err.integrations.occ_failed": "the occ command failed (%s): %s (output: %s)", "err.integrations.occ_disable_failed": "occ app:disable failed: %s (output: %s)", - "err.notify.hub_error": "hub error (%d): %s" + "err.notify.hub_error": "hub error (%d): %s", + "note.offsite.fail_quota": "The remote backup did not fit inside its storage limit", + "note.offsite.fail_orphaned": "The remote store was made with an earlier key that is no longer available", + "note.offsite.fail_no_repo": "There is no backup store at the remote target", + "note.offsite.fail_no_units": "There was nothing to back up: none of the selected apps has a backup", + "note.offsite.fail_transport": "The remote store cannot be reached (network or sign-in)", + "note.offsite.fail_unknown": "The remote backup failed for an unknown reason", + "note.offsite.fail_head": "The remote backup failed", + "note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)", + "note.offsite.no_local_unit": "These apps did NOT go into the remote backup, because they have no local backup unit yet: %s. The next backup usually makes one — if they are still listed after the second run, tell your operator.", + "note.offsite.not_installed": "These apps are selected for remote backup but are not installed, so they cannot be backed up: %s. If you no longer need them, clear their selection on the Remote backup page.", + "note.offsite.quota_partial": "Warning: because of the storage limit, %d apps got only a settings and database backup: %s.", + "note.offsite.quota_usage": "The remote backup uses %d%% of its limit (%d/%d GB).", + "note.tier2.reason_manual": "your own choice", + "note.tier2.reason_other_drive": "another data drive", + "note.tier2.label_internal_ssd": "internal SSD (system)", + "note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive", + "note.tier2.same_disk": "the target you picked is on the same physical disk", + "note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.", + "note.tier2.no_space_ssd": "not enough space on the internal SSD — backing large files off the drive needs a second drive (or remote storage)", + "note.tier2.no_other_drive": "there is no second physical drive — the second backup needs one", + "note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.", + "note.restore.interrupted": "The restore was interrupted (the box restarted) — start it again.", + "note.undo.present": "the earlier state is backed up: %s", + "note.undo.partial": "the earlier state is only PARTLY backed up — present: %s; MISSING: %s", + "note.undo.absent": "the backup of the earlier state is NOT where it should be (%s)", + "note.undo.none": "no restorable copy of the earlier state was made", + "note.reconstitute.copy_latest": "latest", + "note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us", + "layout.nyelv": "Language", + "note.restore.failed": "The restore failed: %s", + "note.restore.full_failed": "The full restore failed: %s", + "note.restore.recover_failed": "The recovery failed: %s", + "note.restore.shares_failed": "Restoring the shares failed: %s", + "note.restore.shares_recover_failed": "Recovering the shares failed: %s", + "note.restore.unit_failed": "Restore failed: %s", + "note.restore.file_failed": "File restore failed: %s", + "note.restore.full_unit_failed": "Full restore failed: %s", + "note.restore.recovered_files": "The missing files of %s are back among your live data.", + "note.restore.shares_prepared": "The shares are restored — you can now put them back among your live data.", + "note.restore.at_scratch": " into the check folder", + "note.restore.at_scratch_named": " into the check folder: %s", + "note.restore.scratch_full": "The full backup of %s is restored%s — together with your own files. Your existing data is unchanged.", + "note.restore.scratch_state": "The settings and the database of %s are restored%s. Your own files (documents, pictures, uploads) did NOT come back — this check restore brings back only the app's settings and database. If you need your files, start “Prepare a full restore” on this page. Your existing data is unchanged.", + "note.restore.files_count": "%d files", + "note.restore.and_volumes": " and %d data volumes", + "note.restore.and_database": " and the database", + "note.restore.volumes_count": "%d data volumes", + "note.restore.the_database": "the database", + "note.restore.dumps_at": " (backup: %s)", + "note.restore.reconstituted": "%s: %s restored%s — the app has restarted.", + "note.restore.no_db_in_backup": " WARNING: this app HAS a database, but the backup held no database dump, so the database did NOT come back. The state from before the restore is backed up: %s", + "note.restore.no_db_at_all": " This app has no database.", + "note.restore.all_files_present": "Every file that was checked is in place.", + "note.restore.files_from_second": "%s: %d files restored from the second copy.", + "note.restore.scratch_incomplete": "The restore copy is incomplete — the last download did not finish. Start “Prepare a full restore” again.", + "note.unit_restore_data": "%s: %s restored — the app has restarted.", + "note.unit_restore_settings_only": "%s: the settings are back — the app has restarted. WARNING: this backup held only the settings, no data. The app's data did NOT come back from this backup.", + "note.unit_restore_counts_unknown": "The restore of %s ran — the app has restarted. This backup has no backup unit, so we cannot tell you what came back from it. Check inside the app that your data is there.", + "note.unit_restore_none_returned": "%s: WARNING — the backup lists %d data volumes and %d database dumps, but none of them came back. Your data is unchanged. Ask for help before you try again.", + "note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.", + "note.tier2_unit_available": "This app's data is not in files but in the app's own database and volumes — the app was not stopped. You can bring those back from the same copy with the “Full restore from the copy” button beside this one. Careful: that action OVERWRITES the current data, while this button only fills in the missing files.", + "note.tier2_unit_not_covered": "The app's database and internal volumes are not part of this restore.", + "note.tier2_unit_restore_source": "The restore came from the copy on the second drive (%s).", + "note.tier2_unit_restore_source_unproven": "The restore came from the copy on the second drive (%s — that is the time of the last backup ATTEMPT; we cannot prove it succeeded).", + "note.tier2_unit_action_label": "Full restore from the copy", + "note.tier2_unit_confirm_base": "This action OVERWRITES the app's current data – its database and its internal volumes too – with the copy on the second drive. Anything made since the copy is lost.", + "note.tier2_unit_confirm_date_fmt": " The copy is from: %s.", + "note.tier2_unit_confirm_date_unproven_fmt": " The copy is from: %s – that is the time of the last backup attempt; we cannot prove it succeeded.", + "note.tier2_unit_confirm_contrast": " The “Restore files” button beside it only fills in the missing files and overwrites nothing. The app stops while either one runs.", + "note.tier2_unit_stale_clause": " WARNING: this copy's data package is older than the latest backup — the package on the main drive was incomplete, so the complete one was kept. The restore uses the package named above.", + "note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 03108ce..444c2c5 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2185,5 +2185,76 @@ "err.integrations.onlyoffice_no_subdomain": "OnlyOffice aldomain nem ismert", "err.integrations.occ_failed": "occ parancs sikertelen (%s): %s (kimenet: %s)", "err.integrations.occ_disable_failed": "occ app:disable sikertelen: %s (kimenet: %s)", - "err.notify.hub_error": "hub hiba (%d): %s" + "err.notify.hub_error": "hub hiba (%d): %s", + "note.offsite.fail_quota": "A távoli mentés nem fért el a tárhelykereten belül", + "note.offsite.fail_orphaned": "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült", + "note.offsite.fail_no_repo": "A távoli tárhelyen nincs mentési adattár", + "note.offsite.fail_no_units": "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése", + "note.offsite.fail_transport": "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)", + "note.offsite.fail_unknown": "A távoli mentés ismeretlen okból nem sikerült", + "note.offsite.fail_head": "A távoli mentés nem sikerült", + "note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)", + "note.offsite.no_local_unit": "Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.", + "note.offsite.not_installed": "Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.", + "note.offsite.quota_partial": "Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.", + "note.offsite.quota_usage": "A távoli mentés a keret %d%%-át használja (%d/%d GB).", + "note.tier2.reason_manual": "kézi választás", + "note.tier2.reason_other_drive": "másik adatmeghajtó", + "note.tier2.label_internal_ssd": "belső SSD (rendszer)", + "note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", + "note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van", + "note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.", + "note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges", + "note.tier2.no_other_drive": "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges", + "note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.", + "note.restore.interrupted": "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra.", + "note.undo.present": "a korábbi állapot mentése megvan: %s", + "note.undo.partial": "a korábbi állapot mentése RÉSZBEN van meg — megvan: %s; HIÁNYZIK: %s", + "note.undo.absent": "a korábbi állapot mentését NEM találjuk a helyén (%s)", + "note.undo.none": "a korábbi állapotról nem készült menthető másolat", + "note.reconstitute.copy_latest": "legutóbbi", + "note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", + "layout.nyelv": "Nyelv", + "note.restore.failed": "A visszaállítás sikertelen: %s", + "note.restore.full_failed": "A teljes visszaállítás sikertelen: %s", + "note.restore.recover_failed": "A helyreállítás sikertelen: %s", + "note.restore.shares_failed": "A megosztások visszaállítása sikertelen: %s", + "note.restore.shares_recover_failed": "A megosztások helyreállítása sikertelen: %s", + "note.restore.unit_failed": "Visszaállítás sikertelen: %s", + "note.restore.file_failed": "Fájl-visszaállítás sikertelen: %s", + "note.restore.full_unit_failed": "Teljes visszaállítás sikertelen: %s", + "note.restore.recovered_files": "A(z) %s hiányzó fájljai helyreállítva az élő adatok közé.", + "note.restore.shares_prepared": "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.", + "note.restore.at_scratch": " ellenőrző mappába", + "note.restore.at_scratch_named": " ellenőrző mappába: %s", + "note.restore.scratch_full": "A(z) %s teljes mentése visszaállítva%s — a saját fájljaiddal együtt. A meglévő adatok változatlanok.", + "note.restore.scratch_state": "A(z) %s beállításai és adatbázisa visszaállítva%s. 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.", + "note.restore.files_count": "%d fájl", + "note.restore.and_volumes": " és %d adatkötet", + "note.restore.and_database": " és az adatbázis", + "note.restore.volumes_count": "%d adatkötet", + "note.restore.the_database": "az adatbázis", + "note.restore.dumps_at": " (mentés: %s)", + "note.restore.reconstituted": "A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", + "note.restore.no_db_in_backup": " 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", + "note.restore.no_db_at_all": " Ennek az alkalmazásnak nincs adatbázisa.", + "note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.", + "note.restore.files_from_second": "%s: %d fájl visszaállítva a másodlagos másolatból.", + "note.restore.scratch_incomplete": "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.", + "note.unit_restore_data": "A(z) %s: %s visszaállítva — az alkalmazás újraindult.", + "note.unit_restore_settings_only": "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.", + "note.unit_restore_counts_unknown": "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.", + "note.unit_restore_none_returned": "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.", + "note.tier2_no_coverage": "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.", + "note.tier2_unit_available": "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.", + "note.tier2_unit_not_covered": "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba.", + "note.tier2_unit_restore_source": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s).", + "note.tier2_unit_restore_source_unproven": "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).", + "note.tier2_unit_action_label": "Teljes visszaállítás a másolatból", + "note.tier2_unit_confirm_base": "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.", + "note.tier2_unit_confirm_date_fmt": " A másolat kelte: %s.", + "note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.", + "note.tier2_unit_confirm_contrast": " 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.", + "note.tier2_unit_stale_clause": " 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.", + "note.tier2_unit_stale_notice_fmt": "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." } diff --git a/controller/internal/util/msgerr.go b/controller/internal/util/msgerr.go index 36578e8..75ddd6b 100644 --- a/controller/internal/util/msgerr.go +++ b/controller/internal/util/msgerr.go @@ -149,3 +149,25 @@ func ErrText(lang string, err error) string { } return err.Error() } + +// Text renders a bundle message in a KNOWN language — for a producer that has no request. +// +// Release C (v0.254.0) uses it for the sentences a background run SAVES: the note under last night's +// backup, the last error, the proof result. Those are written at 03:00 and read days later, so there +// is no reader to ask; the operator ruling (slice 2 §16, option 1) is that they are written in the +// box's language AT WRITE TIME and shown verbatim afterwards. +// +// The consequence, stated rather than hidden: a household that switches language sees last night's +// note in the old language until the next run rewrites it. The alternative — storing a code and +// rendering live — needs a dozen new persisted fields and a legacy path for each, which is the +// R-570 shape a dozen times over. +func Text(lang, key string, args ...interface{}) string { + b, err := i18n.Shared() + if err != nil { + return key + } + if len(args) == 0 { + return b.Msg(lang, key) + } + return b.Msgf(lang, key, args...) +} diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index 3d99bdc..f663e61 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -82,6 +82,14 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { return } + // The anonymous language switch (v0.254.0, R-557). The sign-in page shows a globe, so the + // globe has to work without a session. It writes ONE display-only cookie in the visitor's own + // browser and touches nothing the household owns — langCookieHandler carries the reasoning. + if r.URL.Path == langCookiePath && r.Method == http.MethodPost { + s.langCookieHandler(w, r) + return + } + // Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET // entry (code-gated internally). Static assets for the page too. The guest launcher share // (v0.165.0) joins here — /s/ is a capability URL with NO admin session; the token diff --git a/controller/internal/web/claim.go b/controller/internal/web/claim.go index 2d16e41..4af2f40 100644 --- a/controller/internal/web/claim.go +++ b/controller/internal/web/claim.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "io" "net" "net/http" @@ -246,6 +247,11 @@ func claimPageAllowedPath(path string) bool { switch path { case "/claim", "/claim/request-new-code", "/api/health": return true + case langCookiePath: + // The claim page carries the language globe, so its switch has to work on an unclaimed box — + // which is precisely the box whose first visitor may not read Hungarian (v0.254.0, R-557). + // It sets a display-only cookie and can do nothing else; see langCookieHandler. + return true } return strings.HasPrefix(path, "/static/") } @@ -390,6 +396,23 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) { // flips in seconds — the operator sees the customer claim land immediately. s.reportTriggerNow() s.claimClearFailures(ip) + + // §16 (operator default, v0.254.0): the claim CARRIES the visitor's language. Someone who switched + // the claim page to English and then claimed the box chose English deliberately; the first + // dashboard they see should be in it, and the hub's e-mails will follow once slice 3 lands. + // + // Only here, and only on SUCCESS: this is the one moment an anonymous visitor becomes the + // household, so it is the one moment their display cookie may become the household's setting. + // Every other anonymous request leaves settings.json alone (langFor, CsrfProtect). + if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { + if err := s.settings.SetLanguage(c.Value); err != nil { + // Not fatal: the box is claimed either way, and the household can switch on the dashboard. + s.logger.Printf("[WARN] [web] claim: could not carry the visitor's language %q: %v", c.Value, err) + } else { + s.logger.Printf("[INFO] [web] claim: household language set to %q from the claim page", c.Value) + } + } + s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist action := "claimed" diff --git a/controller/internal/web/csrf.go b/controller/internal/web/csrf.go index cf8b56a..bffb267 100644 --- a/controller/internal/web/csrf.go +++ b/controller/internal/web/csrf.go @@ -42,6 +42,17 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler { return } + // The anonymous language switch (v0.254.0, R-557). Exempt for a narrow and checkable reason: + // the ONLY thing it can achieve is to change the language of the page the victim's own browser + // shows them. It writes one display-only cookie, reads nothing, touches no setting, and its + // redirect cannot leave this box (safeBackPath). A CSRF token here would also be unobtainable: + // the visitor has no session to mint one from. **If this handler ever gains a second effect, + // it needs CSRF that day** — the exemption is for what it does, not for where it lives. + if r.URL.Path == langCookiePath { + next.ServeHTTP(w, r) + return + } + // Skip CSRF for Bearer-token authenticated requests. // Validate the token against the configured API key before skipping. if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 403e18b..56108af 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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) diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 62a881b..996b6f8 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -293,15 +293,37 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string { t.Helper() var buf bytes.Buffer data := c.data() - // The production choke point's language step (executeTemplate → addLanguageData), with a request that - // carries no ?lang= — so the switch and the translated title behave exactly as on a real page. - s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), lang) + r := httptest.NewRequest("GET", "/i18n-fixture", nil) + // TWO production paths, and the fixture has to be rendered through the RIGHT one or it is a + // picture of something no visitor sees (v0.254.0, R-557 release C). + // + // * a dashboard page goes through executeTemplate → addLanguageData: session CSRF, and its + // language globe posts to the HOUSEHOLD switch; + // * the pages outside the dashboard chrome go through executeTemplateLang: no CSRF, and their + // globe posts to /lang, the visitor's own display cookie. + // + // Rendering a shell through addLanguageData would bake a form the real page never serves — which + // is exactly what the first attempt at this release did, and what the diff caught. + if i18nDirectTemplates[c.tmpl] { + data["Lang"] = lang + addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "") + } else { + s.addLanguageData(data, r, lang) + } if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil { t.Fatalf("%s [%s]: render: %v", c.name, lang, err) } return relativeAgeRe.ReplaceAllString(buf.String(), "# $1") } +// i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no +// household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in +// i18n_wiring_test.go) names the same set with one case and an English probe each. +var i18nDirectTemplates = map[string]bool{ + "login": true, "claim": true, "recovery": true, + "launcher_shared": true, "launcher_share_password": true, "catchall": true, +} + func i18nTestServer(t *testing.T) *Server { s := testServer(t) s.loadTemplates() diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index e6a2644..9ffdaf1 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -193,6 +193,10 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l if r != nil { data["LangSwitch"] = true data["LangSwitchBack"] = r.URL.Path + // v0.254.0: the dashboard globe posts to the HOUSEHOLD switch — session CSRF, and it writes + // settings.json. It never writes the visitor cookie: a signed-in household's choice belongs in + // their settings, not in whichever browser they happen to be using. + addLangOptions(data, lang, "/settings/language", r.URL.Path, s.csrfField(r)) } // The alert banners were stored by a background health cycle and put into the page data by // baseData, which has no request and therefore no language. Re-rendered here, where the language @@ -218,6 +222,38 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l } } +// LangOption is one entry in the globe menu. Name is the language's OWN name and is never +// translated — a person looking for their language looks for the word they know. +type LangOption struct { + Code string + Name string + Current bool +} + +// langNativeNames — a language's own name, in itself. Not in the bundle on purpose: a bundle entry +// would invite a translation, and „Magyar" translated into English is still „Magyar". +var langNativeNames = map[string]string{"hu": "Magyar", "en": "English"} + +// addLangOptions puts everything the lang_globe partial needs into a page's data. +// +// `csrf` is template.HTML and may be empty: the dashboard posts to the household switch and needs a +// session CSRF field, the anonymous shells post to /lang and have no session to mint one from (the +// exemption and its reasoning are in CsrfProtect). +func addLangOptions(data map[string]interface{}, lang, action, back string, csrf template.HTML) { + opts := make([]LangOption, 0, len(i18n.Supported)) + for _, code := range i18n.Supported { + name := langNativeNames[code] + if name == "" { + name = code + } + opts = append(opts, LangOption{Code: code, Name: name, Current: code == lang}) + } + data["LangOptions"] = opts + data["LangAction"] = action + data["LangBack"] = back + data["LangCSRF"] = csrf +} + // languageSwitchHandler stores the household's language (POST /settings/language) and goes back to // the page it came from. CSRF is enforced by the CsrfProtect middleware wrapping "/" (main.go), as for // every other dashboard form; the switch form carries {{.CSRFField}}. @@ -358,3 +394,94 @@ func stateLabelKey(state stacks.ContainerState) string { return "func.state.unknown" } } + +// ── The visitor's language, for a page with no household signed in (v0.254.0, R-557 release C) ── +// +// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no +// setting to read, and they must not be able to write the household's: a box's sign-in page is +// reachable by anyone who can reach the box, and changing what the HOUSEHOLD reads from there would +// be an anonymous write to something they own. Changing what THEY THEMSELVES read is not, and that is +// the whole of what this cookie does. +// +// So: display-only, one of two values, their browser, never the household's setting. `langFor` reads +// it only when there is no session (server.go), so a signed-in household can never inherit a language +// a previous visitor picked in the same browser. + +// langCookiePath is the anonymous switch's route. +const langCookiePath = "/lang" + +// langCookieName is the visitor's display-language cookie. +const langCookieName = "felhom_lang" + +// langCookieMaxAge — a year. A visitor who set it once should not have to set it again, and there is +// nothing here worth expiring. +const langCookieMaxAge = 365 * 24 * 60 * 60 + +// langCookieHandler serves POST /lang: the anonymous language switch. +// +// NO CSRF, deliberately and narrowly: the only thing a forged request can achieve is to change the +// language of the page the VICTIM'S OWN BROWSER shows them, which is neither a secret nor the +// household's. The handler cannot touch settings.json, cannot read anything, and cannot redirect off +// this box (see the `back` check). If it ever gains a second effect, it needs CSRF that day. +func (s *Server) langCookieHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + lang := r.FormValue("lang") + if !i18n.IsSupported(lang) { + // Refuse rather than guess: an unsupported value is a bug or a probe, and silently writing + // Hungarian would hide both. + http.Error(w, "unsupported language", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{ + Name: langCookieName, + Value: lang, + Path: "/", + HttpOnly: true, // nothing on the page needs to read it; the server does + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + MaxAge: langCookieMaxAge, + }) + http.Redirect(w, r, safeBackPath(r.FormValue("back")), http.StatusSeeOther) +} + +// safeBackPath keeps an open redirect out of the one handler that takes a destination from an +// anonymous request. +// +// Only a same-origin PATH is allowed. `//evil.example` is rejected too: a browser reads a +// protocol-relative URL as another origin, so "starts with /" alone is not the test — which is the +// mistake this function exists to not make. +func safeBackPath(back string) string { + if back == "" || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") { + return "/" + } + if strings.Contains(back, "\\") || strings.ContainsAny(back, "\r\n") { + return "/" + } + return back +} + +// ── Saved notes (v0.254.0, release C) ────────────────────────────────────────────────────────── +// +// A restore runs in a goroutine with no request and finishes minutes later; its outcome is SAVED and +// read on a page afterwards. There is no reader to ask, so the note is written in the BOX's language +// at the moment it is written — the operator's ruling (slice 2 §16, option 1). +// +// The consequence, stated rather than hidden: a household that switches language sees the note from +// the run before in the old language, until the next run rewrites it. + +// boxLang is the language a SAVED note is written in. +func (s *Server) boxLang() string { + if s.settings == nil { + return i18n.Default + } + return s.settings.GetLanguage() +} + +// note renders a saved note in the box's language. +func (s *Server) note(key string, args ...interface{}) string { + return util.Text(s.boxLang(), key, args...) +} + +// noteErr renders an error into a saved note in the box's language: its bundle message when it +// carries one (release B), its own text otherwise. +func (s *Server) noteErr(err error) string { return util.ErrText(s.boxLang(), err) } diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go index d8ff533..9360200 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -303,7 +303,10 @@ func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) { t.Fatal(err) } var buf strings.Builder - if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/x", nil), p.tmpl, c.data()); err != nil { + // The SAME request path the parity harness renders with. Since v0.254.0 the page carries a + // language globe whose `back` is the path the visitor is on, so a different path here would + // differ from the fixture in one attribute and say nothing about the language. + if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil { t.Fatalf("%s [%s]: %v", p.tmpl, lang, err) } got := relativeAgeRe.ReplaceAllString(buf.String(), "# $1") diff --git a/controller/internal/web/lang_visitor_test.go b/controller/internal/web/lang_visitor_test.go new file mode 100644 index 0000000..c1beefc --- /dev/null +++ b/controller/internal/web/lang_visitor_test.go @@ -0,0 +1,375 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) + +// Localisation slice 2 release C (R-557), scenarios S2–S4. +// +// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no +// setting to read and must not be able to write the household's — a box's sign-in page is reachable +// by anyone who can reach the box. So their choice lives in their own browser, and the household's +// setting is untouched until the one moment an anonymous visitor BECOMES the household: a successful +// claim (§16). + +func langReq(method, path string, cookies ...*http.Cookie) *http.Request { + r := httptest.NewRequest(method, path, nil) + for _, c := range cookies { + r.AddCookie(c) + } + return r +} + +func langCookie(v string) *http.Cookie { return &http.Cookie{Name: langCookieName, Value: v} } + +// newTestSession mints a real session on a Server built by testServer, whose session map the +// production constructor would have made. A REAL session (not a made-up cookie value) because +// langFor asks isValidSession, and a test that handed it an invalid one would be testing the +// no-session path while claiming to test the session path. +func newTestSession(s *Server) string { + s.sessionsMu.Lock() + if s.sessions == nil { + s.sessions = map[string]*session{} + } + s.sessionsMu.Unlock() + return s.createSession() +} + +// TestLangForPrecedence — the order is fixed and each step exists for a different reader. A table, +// because the interesting failures are the CROSSINGS: a signed-in household inheriting a visitor's +// cookie, or a visitor's `?lang=` leaking into the setting. +// +// RED-PROOF (REPORT): drop the `!s.hasSession(r)` guard in langFor → the "session wins over cookie" +// rows fail, which is the row that protects the household. +func TestLangForPrecedence(t *testing.T) { + cases := []struct { + name string + query string + session bool + cookie string + saved string + want string + }{ + {name: "nothing at all → Hungarian", saved: "hu", want: "hu"}, + {name: "the household's saved setting", saved: "en", want: "en"}, + {name: "?lang= overrides the setting (testing door)", query: "en", saved: "hu", want: "en"}, + {name: "?lang= overrides a session too", query: "hu", session: true, saved: "en", want: "hu"}, + {name: "?lang= with an unsupported value is ignored", query: "de", saved: "hu", want: "hu"}, + {name: "no session → the visitor's cookie wins", cookie: "en", saved: "hu", want: "en"}, + {name: "no session, unsupported cookie → the setting", cookie: "de", saved: "hu", want: "hu"}, + {name: "no session, no cookie → the setting", saved: "en", want: "en"}, + // The one that matters: a household that signed in must never read a language a previous + // visitor picked in the same browser. + {name: "SESSION → the household's setting, cookie NOT read", session: true, cookie: "en", saved: "hu", want: "hu"}, + {name: "SESSION → the household's setting, the other way round", session: true, cookie: "hu", saved: "en", want: "en"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t) + if err := s.settings.SetLanguage(tc.saved); err != nil { + t.Fatal(err) + } + path := "/launcher" + if tc.query != "" { + path += "?lang=" + tc.query + } + var cookies []*http.Cookie + if tc.cookie != "" { + cookies = append(cookies, langCookie(tc.cookie)) + } + if tc.session { + cookies = append(cookies, &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}) + } + if got := s.langFor(langReq(http.MethodGet, path, cookies...)); got != tc.want { + t.Errorf("langFor = %q, want %q", got, tc.want) + } + // Whatever happened, reading a page never writes the household's setting. + if got := s.settings.GetLanguage(); got != tc.saved { + t.Errorf("the household's saved language changed to %q — reading a page must never write it", got) + } + }) + } +} + +// TestLangCookieHandler — POST /lang. The whole of what it may do, and the whole of what it must +// refuse. +func TestLangCookieHandler(t *testing.T) { + t.Run("a supported language sets the cookie and returns to the page", func(t *testing.T) { + s := testServer(t) + if err := s.settings.SetLanguage("hu"); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back=%2Flogin")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.langCookieHandler(w, r) + + if w.Code != http.StatusSeeOther { + t.Errorf("status %d, want 303", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/login" { + t.Errorf("Location %q, want /login", loc) + } + var found *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == langCookieName { + found = c + } + } + if found == nil { + t.Fatal("no felhom_lang cookie was set") + } + if found.Value != "en" || !found.HttpOnly || found.SameSite != http.SameSiteLaxMode || found.Path != "/" { + t.Errorf("cookie attributes wrong: %+v", found) + } + // THE POINT: the household's setting is untouched by an anonymous request. + if got := s.settings.GetLanguage(); got != "hu" { + t.Errorf("an anonymous POST changed the household's language to %q", got) + } + }) + + t.Run("an unsupported language is refused and sets nothing", func(t *testing.T) { + s := testServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=de&back=%2Flogin")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.langCookieHandler(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400 — silently writing Hungarian would hide the bug", w.Code) + } + if len(w.Result().Cookies()) != 0 { + t.Errorf("a cookie was set for an unsupported language: %v", w.Result().Cookies()) + } + }) + + t.Run("back may only be a same-origin path", func(t *testing.T) { + for _, tc := range []struct{ back, want string }{ + {"/login", "/login"}, + {"/settings/security", "/settings/security"}, + {"", "/"}, + {"https://evil.example/x", "/"}, + {"//evil.example/x", "/"}, // protocol-relative: a browser reads this as another origin + {"http://evil.example", "/"}, + {"/x\r\nSet-Cookie: a=b", "/"}, // header injection through the redirect + {`/x\..\y`, "/"}, + } { + s := testServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back="+urlQueryEscape(tc.back))) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.langCookieHandler(w, r) + if loc := w.Header().Get("Location"); loc != tc.want { + t.Errorf("back=%q → Location %q, want %q", tc.back, loc, tc.want) + } + } + }) +} + +// TestGlobeOnAnonymousShells — the three pages a visitor meets carry the globe, it posts to /lang with +// NO CSRF field, and `` follows the visitor's cookie rather than the household's setting. +func TestGlobeOnAnonymousShells(t *testing.T) { + cases := map[string]i18nCase{} + for _, c := range i18nCases() { + cases[c.name] = c + } + for _, p := range i18nDirectPages { + if p.tmpl == "launcher_shared" || p.tmpl == "launcher_share_password" || p.tmpl == "catchall" { + continue // deliberately NO globe — a share visitor is a stranger (R-577), and the + // not-found page has nothing to do + } + c := cases[p.caseName] + t.Run(p.tmpl, func(t *testing.T) { + s := i18nTestServer(t) + if err := s.settings.SetLanguage("hu"); err != nil { + t.Fatal(err) + } + var b strings.Builder + r := langReq(http.MethodGet, "/i18n-fixture", langCookie("en")) + if err := s.executeTemplateLang(&b, r, p.tmpl, c.data()); err != nil { + t.Fatal(err) + } + got := b.String() + if !strings.Contains(got, `class="shell-lang"`) { + t.Error("the page carries no language globe") + } + if !strings.Contains(got, `action="/lang"`) { + t.Error("the globe does not post to /lang — a visitor must not write the household's setting") + } + // Scoped to the GLOBE block. The claim page carries its own pre-auth CSRF field for the + // claim form itself, so a page-wide search would convict the wrong form — and did, on the + // first run. + gi := strings.Index(got, `class="shell-lang"`) + ge := strings.Index(got[gi:], "
") + if gi < 0 || ge < 0 { + t.Fatal("could not isolate the globe block") + } + globe := got[gi : gi+ge] + if strings.Contains(globe, `name="_csrf"`) { + t.Error("the anonymous globe carries a CSRF field; there is no session to mint one from") + } + if !strings.Contains(globe, `action="/lang"`) { + t.Error("the globe block does not post to /lang") + } + // The cookie decided the language, not the household's `hu`. + if !strings.Contains(got, `\n "); j > 0 { + foot = foot[:j] + } + for _, want := range []string{`class="version"`, `class="lang-globe"`, `class="logout-link"`} { + if !strings.Contains(foot, want) { + t.Errorf("the footer is missing %s", want) + } + } + // Order: version, then globe, then sign-out. + v, g, l := strings.Index(foot, `class="version"`), strings.Index(foot, `class="lang-globe"`), strings.Index(foot, `class="logout-link"`) + if !(v < g && g < l) { + t.Errorf("footer order is version=%d globe=%d logout=%d, want version < globe < logout", v, g, l) + } + // And the OLD two-link switch is gone — otherwise both could be present and the test would pass. + if strings.Contains(got, "lang-switch-btn") { + t.Error("the old two-text-link switch is still rendered beside the globe") + } +} + +// TestClaimCarriesLanguage — §16. A visitor who switched the claim page to English and then claimed +// the box gets an English dashboard. Only on SUCCESS, and only here: this is the one moment an +// anonymous visitor becomes the household. +func TestClaimCarriesLanguage(t *testing.T) { + t.Run("a successful claim carries the cookie into the setting", func(t *testing.T) { + s := testServer(t) + if err := s.settings.SetLanguage("hu"); err != nil { + t.Fatal(err) + } + // The carry is one block in handleClaimSubmit, after every gate. Exercised here through the + // same two calls it makes, because a full claim needs a live code the fixture cannot mint. + r := langReq(http.MethodPost, "/claim", langCookie("en")) + if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { + if err := s.settings.SetLanguage(c.Value); err != nil { + t.Fatal(err) + } + } + if got := s.settings.GetLanguage(); got != "en" { + t.Errorf("the household's language is %q, want en", got) + } + }) + + t.Run("an unsupported cookie is ignored", func(t *testing.T) { + s := testServer(t) + if err := s.settings.SetLanguage("hu"); err != nil { + t.Fatal(err) + } + r := langReq(http.MethodPost, "/claim", langCookie("de")) + if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { + t.Fatal("an unsupported cookie was accepted") + } + if got := s.settings.GetLanguage(); got != "hu" { + t.Errorf("the household's language changed to %q", got) + } + }) + + // The SOURCE half: the carry is inside handleClaimSubmit, AFTER the failure paths return, so a + // failed claim cannot reach it. A behaviour test cannot show that without a live claim code; the + // position in the function is what makes it true, and the position is what this reads. + t.Run("the carry sits after every refusal", func(t *testing.T) { + src := mustReadSource(t, "claim.go") + fn := src[strings.Index(src, "func (s *Server) handleClaimSubmit"):] + carry := strings.Index(fn, "claim: household language set to") + clear := strings.Index(fn, "s.claimClearFailures(ip)") + if carry < 0 || clear < 0 { + t.Fatal("the claim carry or the success marker moved — this test no longer reads what it names") + } + if carry < clear { + t.Error("the language carry runs BEFORE the failures are cleared — a failed claim could reach it") + } + }) +} + +// mustReadSource reads a file in this package, for the few tests whose claim is about WHERE code sits +// rather than what it returns. +func mustReadSource(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// noteServer builds a Server whose saved-note helpers work — a note is written in the BOX's language +// (release C), so a Server with no settings would render every note as its key. Hungarian, because +// these tests assert the sentence a Hungarian household reads: the parity half. +func noteServer(t *testing.T) *Server { + t.Helper() + s := i18nTestServer(t) + if err := s.settings.SetLanguage("hu"); err != nil { + t.Fatal(err) + } + return s +} + +// noteHU is the Hungarian text of a saved-note key, for a test that used to compare against a Go +// constant. Same claim, now measured against the bundle instead of restated beside it. +func noteHU(t *testing.T, key string) string { + t.Helper() + return noteServer(t).note(key) +} diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index 639a688..377a79b 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -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)", diff --git a/controller/internal/web/r103_tier2_action_test.go b/controller/internal/web/r103_tier2_action_test.go index 0e5bfae..8fdf5da 100644 --- a/controller/internal/web/r103_tier2_action_test.go +++ b/controller/internal/web/r103_tier2_action_test.go @@ -32,7 +32,7 @@ import ( // r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it // will render the actions block at all. -func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow { +func r103Row(t *testing.T, unitRestorable bool, date string, proven bool) AppBackupRow { row := AppBackupRow{ StackName: "docmost", DisplayName: "Docmost", Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta", @@ -42,7 +42,7 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow { } if unitRestorable { row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven - row.Tier2UnitConfirm = tier2UnitConfirmMsg(date, proven) + row.Tier2UnitConfirm = noteServer(t).tier2UnitConfirmMsg(date, proven) } return row } @@ -50,13 +50,13 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow { // D1 — TestR103_UnitActionOfferedWhenTheMirrorExists. func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) { html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ - r103Row(true, "2026-08-25T03:30:00Z", true), + r103Row(t, true, "2026-08-25T03:30:00Z", true), })) if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) { t.Error("the unit-restore form is not on the page — the refusal is still a dead end") } - if !strings.Contains(html, tier2UnitActionLabel) { - t.Errorf("the action is not labelled %q", tier2UnitActionLabel) + if !strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) { + t.Errorf("the action is not labelled %q", noteHU(t, tier2UnitActionLabelKey)) } // The additive action must still be there, separately. Merging them is forbidden: they are // different promises (one adds, one overwrites). @@ -74,12 +74,12 @@ func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) { // button, because it is a promise withdrawn at the moment of use. func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) { html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ - r103Row(false, "2026-08-25T03:30:00Z", true), + r103Row(t, false, "2026-08-25T03:30:00Z", true), })) if strings.Contains(html, "/backup/tier2/unit-restore") { t.Error("the unit action was offered for a copy with no openable unit") } - if strings.Contains(html, tier2UnitActionLabel) { + if strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) { t.Error("the unit action's label leaked onto a row that must not offer it") } // NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not @@ -93,18 +93,18 @@ func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) { // requires: a destructive operation reached from a non-destructive surface says so, and says how it // differs from the action beside it. func TestR103_ConfirmStatesTheOverwrite(t *testing.T) { - confirm := tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true) + confirm := noteServer(t).tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true) - if !strings.Contains(confirm, tier2UnitConfirmBase) { + if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmBaseKey)) { t.Error("the confirm does not state that the operation OVERWRITES live data") } - if !strings.Contains(confirm, tier2UnitConfirmContrast) { + if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmContrastKey)) { t.Error("the confirm does not state how it differs from the additive restore beside it") } // NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language. // Without it, a test that passed because both buttons warn about overwriting would look green. html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ - r103Row(true, "2026-08-25T03:30:00Z", true), + r103Row(t, true, "2026-08-25T03:30:00Z", true), })) fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat") if fileConfirmStart < 0 { @@ -136,11 +136,11 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) { t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous") } - proven := tier2UnitConfirmMsg(stamp, true) + proven := noteServer(t).tier2UnitConfirmMsg(stamp, true) if !strings.Contains(proven, rendered) { t.Errorf("the confirm does not name the copy's date: %q", proven) } - unproven := tier2UnitConfirmMsg(stamp, false) + unproven := noteServer(t).tier2UnitConfirmMsg(stamp, false) if !strings.Contains(unproven, rendered) { t.Errorf("the unproven confirm does not name the date: %q", unproven) } @@ -148,13 +148,13 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) { t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly") } // A copy with no recorded date still gets the warning; it just cannot name one. - none := tier2UnitConfirmMsg("", false) - if !strings.Contains(none, tier2UnitConfirmBase) || !strings.Contains(none, tier2UnitConfirmContrast) { + none := noteServer(t).tier2UnitConfirmMsg("", false) + if !strings.Contains(none, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(none, noteHU(t, tier2UnitConfirmContrastKey)) { t.Errorf("a dateless copy lost its confirm entirely: %q", none) } // And it is on the page, not merely constructible. - html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(true, stamp, true)})) + html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(t, true, stamp, true)})) if !strings.Contains(html, rendered) { t.Errorf("the copy's date %q is not in the rendered row", rendered) } @@ -163,23 +163,25 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) { // TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must // name it by the label the button actually carries, or it points at nothing. func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) { - if !strings.Contains(tier2UnitAvailableMsg, tier2UnitActionLabel) { - t.Errorf("tier2UnitAvailableMsg does not name %q", tier2UnitActionLabel) + if !strings.Contains(noteHU(t, tier2UnitAvailableKey), noteHU(t, tier2UnitActionLabelKey)) { + t.Errorf("noteHU(t, tier2UnitAvailableKey) does not name %q", noteHU(t, tier2UnitActionLabelKey)) } // It must NOT send anyone to another page any more; that is the R-103 defect it replaces. - if strings.Contains(tier2UnitAvailableMsg, "oldalon") { + if strings.Contains(noteHU(t, tier2UnitAvailableKey), "oldalon") { t.Error("the message still routes the customer to another page for a copy restorable here") } // The surviving no-coverage message still names the route that works. - if !strings.Contains(tier2NoCoverageMsg, "oldalon") { - t.Error("tier2NoCoverageMsg no longer names any route — Scenario G requires one") + if !strings.Contains(noteHU(t, tier2NoCoverageKey), "oldalon") { + t.Error("noteHU(t, tier2NoCoverageKey) no longer names any route — Scenario G requires one") } - // C3 — tier2UnitNotCoveredMsg is NOT deleted: it is still appended where the FILE restore ran. - if tier2UnitNotCoveredMsg == "" { - t.Fatal("tier2UnitNotCoveredMsg was deleted") + // C3 — the not-covered sentence is NOT deleted: it is still appended where the FILE restore ran. + // Since v0.254.0 it is a SAVED note written in the box's language, so the key is what the source + // names and the bundle is what carries the sentence; both halves are checked. + if noteHU(t, tier2UnitNotCoveredKey) == "" { + t.Fatal("the not-covered sentence was deleted from the bundle") } - if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + tier2UnitNotCoveredMsg`) { - t.Error("tier2UnitNotCoveredMsg is no longer appended by the file-restore handler") + if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + s.note(tier2UnitNotCoveredKey)`) { + t.Error("the not-covered sentence is no longer appended by the file-restore handler") } } @@ -345,8 +347,8 @@ func TestR103_HandlerPublishesTheOutcome(t *testing.T) { } // The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live // data (Scenario E). Asserted as an exact composition so neither half can silently vanish. - wantOutcome := unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{}) - wantSource := tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp}) + wantOutcome := noteServer(t).unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{}) + wantSource := noteServer(t).tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp}) if wantSource == "" { t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous") } @@ -369,8 +371,10 @@ func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T) if !strings.Contains(loc, "flash_error") { t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc) } - if !strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) { - t.Errorf("refusal flash = %q, want tier2NoCoverageMsg", loc) + // The flash carries a KEY since v0.252.0; the sentence is what this test is about, so it is + // resolved the way the page resolves it. + if got := flashSentence(t, loc); got != noteHU(t, tier2NoCoverageKey) { + t.Errorf("refusal flash = %q, want the no-coverage sentence", got) } if st := m.RestoreStatus(); st.Running || st.Last != nil { t.Errorf("an operation was begun despite the refusal: %+v", st) @@ -404,10 +408,10 @@ func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) { s, _ := newR103Server(t, true) rec := postTier2Restore(t, s, "app") loc := rec.Header().Get("Location") - if !strings.Contains(loc, url.QueryEscape(tier2UnitAvailableMsg)) { - t.Errorf("refusal flash = %q, want tier2UnitAvailableMsg", loc) + if !strings.Contains(loc, url.QueryEscape(noteHU(t, tier2UnitAvailableKey))) { + t.Errorf("refusal flash = %q, want noteHU(t, tier2UnitAvailableKey)", loc) } - if strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) { + if strings.Contains(loc, url.QueryEscape(noteHU(t, tier2NoCoverageKey))) { t.Error("the old dead-end message is still shown for a copy restorable here") } } diff --git a/controller/internal/web/r353_unit_outcome_test.go b/controller/internal/web/r353_unit_outcome_test.go index ef05164..24ee9a2 100644 --- a/controller/internal/web/r353_unit_outcome_test.go +++ b/controller/internal/web/r353_unit_outcome_test.go @@ -31,7 +31,7 @@ import ( // whether the app has data (R-361 destroyed apps' canonical .sql files for four months). func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) { - msg := unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{ + msg := noteServer(t).unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{ VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1, }) for _, want := range []string{"2 adatkötet", "az adatbázis"} { @@ -48,7 +48,7 @@ func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) { } func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) { - msg := unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{}) + msg := noteServer(t).unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{}) for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} { if !strings.Contains(msg, want) { t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg) @@ -64,7 +64,7 @@ func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) { } func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) { - msg := unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{ + msg := noteServer(t).unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{ VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1, }) for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} { @@ -79,7 +79,7 @@ func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) { } func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) { - msg := unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{ + msg := noteServer(t).unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{ VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1, }) if !strings.Contains(msg, "az adatbázis visszaállítva") { @@ -199,7 +199,7 @@ func TestR353_HandlerPublishesTheOutcome(t *testing.T) { // entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what // this whole change exists to remove — so it must not be re-introduced by the fix itself. func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) { - msg := unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true}) + msg := noteServer(t).unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true}) if strings.Contains(msg, "csak a beállításokat tartalmazta") { t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " + diff --git a/controller/internal/web/r355_outcome_msg_test.go b/controller/internal/web/r355_outcome_msg_test.go index f051685..33036bf 100644 --- a/controller/internal/web/r355_outcome_msg_test.go +++ b/controller/internal/web/r355_outcome_msg_test.go @@ -17,7 +17,7 @@ import ( func TestReconstituteOutcome_NoDatabaseOnlyWhenThereIsNone(t *testing.T) { msg := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{ FilesPlaced: 3, DBsReplayed: 0, SafetyDump: "", - }) + }, "hu") if !strings.Contains(msg, "nincs adatbázisa") { t.Errorf("an app with genuinely no database should still say so; got %q", msg) } @@ -29,7 +29,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) { msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{ FilesPlaced: 0, DBsReplayed: 0, SafetyDump: "/mnt/x/db-dumps/pre-restore-20260822T060000Z-paperless-ngx-postgres.sql", - }) + }, "hu") if strings.Contains(msg, "nincs adatbázisa") { t.Fatalf("FALSE CLAIM: told the customer the app has no database while its undo copy proves it does; got %q", msg) } @@ -51,7 +51,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) { func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) { msg := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{ FilesPlaced: 4, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql", - }) + }, "hu") if !strings.Contains(msg, "és az adatbázis visszaállítva") { t.Errorf("the full case must keep its wording; got %q", msg) } @@ -66,7 +66,7 @@ func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) { func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) { msg := reconstituteOutcomeMsg("calibre-web", backup.OffsiteReconstituteResult{ FilesPlaced: 5, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "", - }) + }, "hu") if !strings.Contains(msg, "5 fájl és 1 adatkötet visszaállítva") { t.Errorf("the message must name the volume that came back; got %q", msg) } @@ -80,7 +80,7 @@ func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) { func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) { msg := reconstituteOutcomeMsg("privatebin", backup.OffsiteReconstituteResult{ FilesPlaced: 0, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "", - }) + }, "hu") if !strings.Contains(msg, "0 fájl és 1 adatkötet visszaállítva") { t.Errorf("a volume-only restore must state the volume; got %q", msg) } @@ -91,13 +91,13 @@ func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) { func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) { noDB := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{ FilesPlaced: 3, VolumesReplayed: 0, DBsReplayed: 0, SafetyDump: "", - }) + }, "hu") if noDB != "A(z) opengist: 3 fájl visszaállítva — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa." { t.Errorf("the no-volume, no-database wording changed; got %q", noDB) } withDB := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{ FilesPlaced: 4, VolumesReplayed: 0, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql", - }) + }, "hu") if withDB != "A(z) romm: 4 fájl és az adatbázis visszaállítva — az alkalmazás újraindult." { t.Errorf("the no-volume, with-database wording changed; got %q", withDB) } @@ -110,7 +110,7 @@ func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) { func TestReconstituteOutcome_AllThreeLegs(t *testing.T) { msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{ FilesPlaced: 12, VolumesReplayed: 3, DBsReplayed: 1, SafetyDump: "/x/pre-restore-p.sql", - }) + }, "hu") want := "A(z) paperless-ngx: 12 fájl és 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult." if msg != want { t.Errorf("got %q\nwant %q", msg, want) diff --git a/controller/internal/web/r358_r360_handlers_test.go b/controller/internal/web/r358_r360_handlers_test.go index efa7e12..8e171c8 100644 --- a/controller/internal/web/r358_r360_handlers_test.go +++ b/controller/internal/web/r358_r360_handlers_test.go @@ -80,8 +80,10 @@ func TestR358_PlaceHandlerRefusesIncompleteScratch(t *testing.T) { s.offboxPlaceHandler(w, req) loc := w.Header().Get("Location") - if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") { - t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q", loc) + // The flash carries a KEY since v0.252.0; resolved here the way the page resolves it, so the + // assertion is still about the SENTENCE the customer reads. + if !strings.Contains(flashSentence(t, loc), "nem teljes") { + t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q -> %q", loc, flashSentence(t, loc)) } if m.RestoreStatus().Running { t.Fatal("the place operation actually STARTED over an incomplete scratch") @@ -101,8 +103,8 @@ func TestR358_ReconstituteHandlerRefusesIncompleteScratch(t *testing.T) { s.offboxReconstituteHandler(w, req) loc := w.Header().Get("Location") - if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") { - t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q", loc) + if !strings.Contains(flashSentence(t, loc), "nem teljes") { + t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q -> %q", loc, flashSentence(t, loc)) } if m.RestoreStatus().Running { t.Fatal("the destructive restore actually STARTED over an incomplete scratch") diff --git a/controller/internal/web/r403_surface_test.go b/controller/internal/web/r403_surface_test.go index 003e0e8..3e795e6 100644 --- a/controller/internal/web/r403_surface_test.go +++ b/controller/internal/web/r403_surface_test.go @@ -22,9 +22,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) { const runDate = "2026-08-31T03:30:00Z" const pkgDate = "2026-08-25T03:30:00Z" - stale := r103Row(true, pkgDate, true) + stale := r103Row(t, true, pkgDate, true) stale.Tier2LastRun, stale.Tier2LastSuccess = runDate, runDate - stale.Tier2UnitStaleNotice = staleNoticeFor(pkgDate) + stale.Tier2UnitStaleNotice = staleNoticeFor(t, pkgDate) html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{stale})) @@ -38,9 +38,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) { // NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that // shows the warning to everybody. - fresh := r103Row(true, runDate, true) + fresh := r103Row(t, true, runDate, true) freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{fresh})) - if strings.Contains(freshHTML, staleNoticeFor(pkgDate)) { + if strings.Contains(freshHTML, staleNoticeFor(t, pkgDate)) { t.Error("an ordinary row carried the preserved-package notice") } if !strings.Contains(freshHTML, "/backup/tier2/unit-restore") { @@ -48,8 +48,8 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) { } } -func staleNoticeFor(pkgDate string) string { - return strings.Replace(tier2UnitStaleNoticeFmt, "%s", fmtRFC3339Local(pkgDate), 1) +func staleNoticeFor(t *testing.T, pkgDate string) string { + return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate)) } // D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate. @@ -58,21 +58,21 @@ func staleNoticeFor(pkgDate string) string { // preserved leg it must name the PACKAGE's date and say why it is older than the copy's newest run. func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) { const pkgDate = "2026-08-25T03:30:00Z" - staleConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, true) - freshConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, false) + staleConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, true) + freshConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, false) - if !strings.Contains(staleConfirm, tier2UnitStaleClause) { + if !strings.Contains(staleConfirm, noteHU(t, tier2UnitStaleClauseKey)) { t.Error("the confirm does not say the package is older than the newest run") } if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) { t.Error("the confirm does not name the package's date") } // Everything the ordinary confirm promised is still promised. - if !strings.Contains(staleConfirm, tier2UnitConfirmBase) || !strings.Contains(staleConfirm, tier2UnitConfirmContrast) { + if !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmContrastKey)) { t.Error("the stale confirm lost the overwrite warning or the additive contrast") } // NEGATIVE CONTROL: the ordinary confirm must NOT carry the clause. - if strings.Contains(freshConfirm, tier2UnitStaleClause) { + if strings.Contains(freshConfirm, noteHU(t, tier2UnitStaleClauseKey)) { t.Error("an ordinary confirm carried the preserved-package clause") } if staleConfirm == freshConfirm { @@ -80,14 +80,14 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) { } // And it reaches the rendered markup, not only the constant. - row := r103Row(true, pkgDate, true) + row := r103Row(t, true, pkgDate, true) row.Tier2UnitConfirm = staleConfirm html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row})) if !strings.Contains(html, "FIGYELEM") { // ASCII-only fragment, R-364 t.Error("the stale clause never reached the page") } // The ASCII control: the same page WITHOUT the clause must not match. - rowFresh := r103Row(true, pkgDate, true) + rowFresh := r103Row(t, true, pkgDate, true) freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{rowFresh})) if strings.Contains(freshHTML, "FIGYELEM") { t.Error("the ASCII fragment matches a page that has no stale clause — the control fails") @@ -97,7 +97,7 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) { // The two-argument wrapper still produces the ordinary confirm — yesterday's callers are unchanged. func TestR403_TheOrdinaryConfirmIsUnchanged(t *testing.T) { const d = "2026-08-25T03:30:00Z" - if tier2UnitConfirmMsg(d, true) != tier2UnitConfirmWithStaleness(d, true, false) { + if noteServer(t).tier2UnitConfirmMsg(d, true) != noteServer(t).tier2UnitConfirmWithStaleness(d, true, false) { t.Error("the two-argument confirm is no longer the not-stale case") } } @@ -113,7 +113,7 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) { UnitPackageDate: "2026-08-31T09:43:41Z", // the preserved package UnitLegPreserved: true, } - msg := tier2UnitSourceMsg(cov) + msg := noteServer(t).tier2UnitSourceMsg(cov) pkg := fmtRFC3339Local("2026-08-31T09:43:41Z") run := fmtRFC3339Local("2026-08-31T12:23:51Z") if !strings.Contains(msg, pkg) { @@ -124,14 +124,14 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) { } // The confirm and the outcome must agree. date, stale := cov.UnitRestoreDate() - confirm := tier2UnitConfirmWithStaleness(date, true, stale) + confirm := noteServer(t).tier2UnitConfirmWithStaleness(date, true, stale) if !strings.Contains(confirm, pkg) { t.Errorf("the confirm does not name %q either: %q", pkg, confirm) } // NEGATIVE CONTROL: with no preserved leg, the package date IS the fresh one and both agree on it. fresh := backup.Tier2Coverage{CopyLastSuccess: "2026-08-31T12:23:51Z", UnitPackageDate: "2026-08-31T12:23:00Z"} - if !strings.Contains(tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) { + if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) { t.Error("the ordinary outcome stopped naming its own package date") } } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 3f06beb..dc8ba6a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -369,13 +369,32 @@ func (s *Server) templatesFor(lang string) *template.Template { return s.tmpl } -// langFor decides the language of one request: `?lang=hu|en` (a testing override, never persisted), -// else the household's saved setting, else Hungarian. +// langFor decides the language of one request. The order is fixed, and each step exists for a +// different reader (v0.254.0, R-557 release C): +// +// 1. `?lang=hu|en` — the testing override. Never persisted, never sets a cookie. +// 2. A REQUEST WITH A SESSION is the household's own: their saved setting, and the visitor cookie is +// NOT read. A signed-in household must never see a language a previous visitor chose on the +// sign-in page of the same browser. +// 3. A request with NO session is a visitor at the door — sign-in, claim, recovery. They have no +// setting to read and no right to change the household's, so their choice lives in their own +// browser: the `felhom_lang` cookie, display-only. +// 4. The household's setting anyway (a box whose visitor expressed no preference). +// 5. Hungarian. +// +// Why a cookie and not the setting: the sign-in page is reachable by anyone who can reach the box. +// Letting that change what the HOUSEHOLD reads would be an anonymous write to something they own. +// Changing what the VISITOR THEMSELVES reads is not — 04-control-plane-authorization.md. func (s *Server) langFor(r *http.Request) string { if r != nil { if q := r.URL.Query().Get("lang"); i18n.IsSupported(q) { return q } + if !s.hasSession(r) { + if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { + return c.Value + } + } } if s.settings != nil { return s.settings.GetLanguage() @@ -383,6 +402,16 @@ func (s *Server) langFor(r *http.Request) string { return i18n.Default } +// hasSession reports whether this request carries a VALID household session — the same test the auth +// middleware makes, reused rather than restated so the two cannot drift apart. +func (s *Server) hasSession(r *http.Request) bool { + if r == nil { + return false + } + c, err := r.Cookie(sessionCookieName) + return err == nil && s.isValidSession(c.Value) +} + // HubPushStatusData holds hub push status for the monitoring page. type HubPushStatusData struct { LastAttempt time.Time @@ -599,6 +628,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // i18n (v0.247.0): the household's dashboard language. case path == "/settings/language" && r.Method == http.MethodPost: s.languageSwitchHandler(w, r) + // i18n (v0.254.0): the VISITOR's display language, for a page with no household signed in. + // RequireAuth intercepts this on a password-protected box; the route is here so it also works on + // a box with no password set and on an unclaimed one, where the middleware passes straight + // through. Same handler either way. + case path == langCookiePath && r.Method == http.MethodPost: + s.langCookieHandler(w, r) case path == "/settings/notifications" && r.Method == http.MethodPost: s.settingsNotificationsHandler(w, r) case path == "/settings/notifications/test" && r.Method == http.MethodPost: @@ -880,6 +915,17 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string, lang := s.langFor(r) if data != nil { data["Lang"] = lang + // v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household + // switch, and carries NO CSRF field — there is no session to mint one from, and the handler + // writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning). + // + // `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the + // handler is what makes that safe to take from an anonymous form. + back := "/" + if r != nil && r.URL != nil && r.URL.Path != "" { + back = r.URL.Path + } + addLangOptions(data, lang, langCookiePath, back, "") } return s.templatesFor(lang).ExecuteTemplate(w, name, data) } diff --git a/controller/internal/web/slice4_update_test.go b/controller/internal/web/slice4_update_test.go index e45e9cc..101d53e 100644 --- a/controller/internal/web/slice4_update_test.go +++ b/controller/internal/web/slice4_update_test.go @@ -51,7 +51,7 @@ func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) { } if row.Tier2UnitRestorable != cov.CanRestoreUnit() || row.Tier2CopyDate != wantDate || row.Tier2CopyDateProven != (cov.CopyLastSuccess != "") || - row.Tier2UnitConfirm != tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) { + row.Tier2UnitConfirm != noteServer(t).tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) { t.Errorf("the row changed: restorable=%v date=%q proven=%v confirm=%q", row.Tier2UnitRestorable, row.Tier2CopyDate, row.Tier2CopyDateProven, row.Tier2UnitConfirm) } } diff --git a/controller/internal/web/templates/claim.html b/controller/internal/web/templates/claim.html index f2476d0..db2c711 100644 --- a/controller/internal/web/templates/claim.html +++ b/controller/internal/web/templates/claim.html @@ -8,6 +8,7 @@ +
{{template "lang_globe" .}}
diff --git a/controller/internal/web/templates/login.html b/controller/internal/web/templates/login.html index f68cc46..f1b418a 100644 --- a/controller/internal/web/templates/login.html +++ b/controller/internal/web/templates/login.html @@ -8,6 +8,7 @@ +
{{template "lang_globe" .}}
diff --git a/controller/internal/web/testdata/i18n_parity/app_import_empty.html b/controller/internal/web/testdata/i18n_parity/app_import_empty.html index b209ffd..39b9b6c 100644 --- a/controller/internal/web/testdata/i18n_parity/app_import_empty.html +++ b/controller/internal/web/testdata/i18n_parity/app_import_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/app_info_available.html b/controller/internal/web/testdata/i18n_parity/app_info_available.html index e04e486..02d54cc 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_available.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_available.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/app_info_deployed.html b/controller/internal/web/testdata/i18n_parity/app_info_deployed.html index 65904d5..c8e5f4c 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_deployed.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_deployed.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/app_info_installable.html b/controller/internal/web/testdata/i18n_parity/app_info_installable.html index f7b8cba..0b6b2a5 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_installable.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_installable.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html b/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html index 060bad9..c172a80 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/app_info_updating.html b/controller/internal/web/testdata/i18n_parity/app_info_updating.html index d69eac8..7c986e0 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_updating.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_updating.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html b/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html index a52a2d5..d8ce82e 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_error.html b/controller/internal/web/testdata/i18n_parity/backups_apps_error.html index d1a3022..c567fab 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_error.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_error.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html b/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html index 4c5d95e..6981ed7 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_other.html b/controller/internal/web/testdata/i18n_parity/backups_apps_other.html index 3b1f529..43dc3e3 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_other.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_other.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_running.html b/controller/internal/web/testdata/i18n_parity/backups_apps_running.html index 3c9cd7d..4b0d1ca 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_running.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_running.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html b/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html index 9fb6791..90486e1 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_degraded.html b/controller/internal/web/testdata/i18n_parity/backups_degraded.html index 81fd5db..fc3bf27 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_degraded.html +++ b/controller/internal/web/testdata/i18n_parity/backups_degraded.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_empty.html b/controller/internal/web/testdata/i18n_parity/backups_empty.html index 1bfacf5..e1b20d8 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html index 5ac7b19..0944aa3 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html index 1b2fdd2..8107f40 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html +++ b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html b/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html index 2b1998f..9ee339f 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html index 33ff238..3d74d6b 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html index c60d1a2..e9de987 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html index 05c9cc1..f65fed3 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html index 75b9c30..bb519e0 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html index b96dbc8..357bd03 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html index e331653..f7698be 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html index d9344d9..025395c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html index 25d4d62..b008416 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html index a0de748..0c24dbe 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html index 6319a4f..161d9b7 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html index f6ee496..0cc96f4 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html b/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html index 22a6818..6f7c4f2 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_full.html b/controller/internal/web/testdata/i18n_parity/backups_restore_full.html index 6b89747..7955d32 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html b/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html index 8034d7a..7c45636 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html b/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html index ab17dc1..d20408c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html index 2803491..1e2d4ed 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html +++ b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html index d7afb92..a4ddc78 100644 --- a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html +++ b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html @@ -8,6 +8,13 @@ +
    + +
      +
    • +
    • +
    +
    diff --git a/controller/internal/web/testdata/i18n_parity/debug.html b/controller/internal/web/testdata/i18n_parity/debug.html index 166986a..f0ce69f 100644 --- a/controller/internal/web/testdata/i18n_parity/debug.html +++ b/controller/internal/web/testdata/i18n_parity/debug.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html index 9dfb4af..a525944 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html index 42de031..845ff4e 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index 7093c56..13bd511 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html index f28c850..5754521 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html index c28a1ce..490368b 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html b/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html index 36107f8..8a99f47 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/escrow_first.html b/controller/internal/web/testdata/i18n_parity/escrow_first.html index f35eb5e..e2ea58f 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_first.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_first.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html b/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html index eb5d072..9734fea 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/escrow_receremony.html b/controller/internal/web/testdata/i18n_parity/escrow_receremony.html index c8aff7a..ff11b6f 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_receremony.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_receremony.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_empty.html b/controller/internal/web/testdata/i18n_parity/launcher_empty.html index 8e70529..5c0bebe 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_empty.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_full.html b/controller/internal/web/testdata/i18n_parity/launcher_full.html index db54b5f..70a4988 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_full.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html b/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html index 7d81033..395d46e 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html index 1f698b8..47141bb 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html index b5afe90..8e3db93 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html index 2162a2e..a7ad0b9 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/login.html b/controller/internal/web/testdata/i18n_parity/login.html index 9677752..79dd47e 100644 --- a/controller/internal/web/testdata/i18n_parity/login.html +++ b/controller/internal/web/testdata/i18n_parity/login.html @@ -8,6 +8,13 @@ +
    + +
      +
    • +
    • +
    +
    diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html index 7f8e85b..46f467b 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html index 2c77ef8..de3941f 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html index adef1e0..36d7f33 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/recovery_locked_can.html b/controller/internal/web/testdata/i18n_parity/recovery_locked_can.html index ecea28d..b2ebce8 100644 --- a/controller/internal/web/testdata/i18n_parity/recovery_locked_can.html +++ b/controller/internal/web/testdata/i18n_parity/recovery_locked_can.html @@ -9,6 +9,13 @@ +
    + +
      +
    • +
    • +
    +
    diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_full.html b/controller/internal/web/testdata/i18n_parity/settings_security_full.html index 120afa3..51e5905 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html index 4ba6050..a0b6575 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html index f729302..40c932c 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_full.html b/controller/internal/web/testdata/i18n_parity/settings_system_full.html index 02a740c..c06e91c 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html index 97bc7b6..90229c6 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html index 23eaa64..f8ccb4c 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html index 79b51e5..6a21230 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_full.html b/controller/internal/web/testdata/i18n_parity/sharing_full.html index 58f20db..4d17379 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_full.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html index ba8aadc..5d6c3ea 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html index a916169..34786a9 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_off.html b/controller/internal/web/testdata/i18n_parity/sharing_off.html index 337c792..4260d1b 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_off.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_off.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html index d3456a6..c6ce53d 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/stacks_full.html b/controller/internal/web/testdata/i18n_parity/stacks_full.html index e77e17f..9603413 100644 --- a/controller/internal/web/testdata/i18n_parity/stacks_full.html +++ b/controller/internal/web/testdata/i18n_parity/stacks_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_attach.html b/controller/internal/web/testdata/i18n_parity/storage_attach.html index 729feec..005cf7d 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_attach.html +++ b/controller/internal/web/testdata/i18n_parity/storage_attach.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index c273f33..7c68a9c 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index fbf82a8..6faa0a3 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_init.html b/controller/internal/web/testdata/i18n_parity/storage_init.html index 1dc5a18..66111b5 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_init.html +++ b/controller/internal/web/testdata/i18n_parity/storage_init.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_network_full.html b/controller/internal/web/testdata/i18n_parity/storage_network_full.html index b37dab0..77d95c0 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_network_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_network_full.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html b/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html index f215f40..4a484e3 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html +++ b/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html index 6daf645..b68df7e 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html b/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html index 9a449e3..5ff2d5a 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html b/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html index 074fddd..d8d9694 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html b/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html index b73d48b..e2d2ff1 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html @@ -147,7 +147,13 @@
  • Biztonság és hozzáférés
  • diff --git a/controller/internal/web/tier2_honest_message_test.go b/controller/internal/web/tier2_honest_message_test.go index f3be739..7396641 100644 --- a/controller/internal/web/tier2_honest_message_test.go +++ b/controller/internal/web/tier2_honest_message_test.go @@ -126,8 +126,8 @@ func TestTier2RestoreHandler_NoCoverage_RefusesUpFrontAndNamesTheAction(t *testi } // The message names the working action rather than dead-ending. for _, want := range []string{"nem ebből a másolatból", "nem állt le", "Visszaállítás indítása"} { - if !strings.Contains(tier2NoCoverageMsg, want) { - t.Errorf("the refusal message is missing %q:\n%s", want, tier2NoCoverageMsg) + if !strings.Contains(noteHU(t, tier2NoCoverageKey), want) { + t.Errorf("the refusal message is missing %q:\n%s", want, noteHU(t, tier2NoCoverageKey)) } } } @@ -171,7 +171,7 @@ func TestTier2RestoreHandler_CoveredApp_ClaimsOnlyWhatWasExamined(t *testing.T) if !strings.Contains(last.Message, "vizsgált") { t.Errorf("the message does not limit its claim to what was EXAMINED: %q", last.Message) } - if !strings.Contains(last.Message, tier2UnitNotCoveredMsg) { + if !strings.Contains(last.Message, noteHU(t, tier2UnitNotCoveredKey)) { t.Errorf("the message does not disclose that the database and volumes were not covered: %q", last.Message) } } diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 1a6e9e5..811671e 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -818,5 +818,119 @@ "err.integrations.occ_disable_failed": [ "occ app:disable sikertelen: %v (kimenet: %s)" ], - "err.notify.hub_error": "hub hiba (%d): %s" + "err.notify.hub_error": "hub hiba (%d): %s", + "note.offsite.fail_quota": "A távoli mentés nem fért el a tárhelykereten belül", + "note.offsite.fail_orphaned": "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült", + "note.offsite.fail_no_repo": "A távoli tárhelyen nincs mentési adattár", + "note.offsite.fail_no_units": "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése", + "note.offsite.fail_transport": "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)", + "note.offsite.fail_unknown": "A távoli mentés ismeretlen okból nem sikerült", + "note.offsite.fail_head": "A távoli mentés nem sikerült", + "note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)", + "note.offsite.no_local_unit": "Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.", + "note.offsite.not_installed": "Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.", + "note.offsite.quota_partial": "Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.", + "note.offsite.quota_usage": "A távoli mentés a keret %d%%-át használja (%d/%d GB).", + "note.tier2.reason_manual": "kézi választás", + "note.tier2.reason_other_drive": "másik adatmeghajtó", + "note.tier2.label_internal_ssd": "belső SSD (rendszer)", + "note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", + "note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van", + "note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.", + "note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges", + "note.tier2.no_other_drive": "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges", + "note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.", + "note.restore.interrupted": "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra.", + "note.undo.present": [ + "a korábbi állapot mentése megvan: " + ], + "note.undo.partial": [ + "a korábbi állapot mentése RÉSZBEN van meg — megvan: ", + "; HIÁNYZIK: " + ], + "note.undo.absent": [ + "a korábbi állapot mentését NEM találjuk a helyén (", + ")" + ], + "note.undo.none": "a korábbi állapotról nem készült menthető másolat", + "note.reconstitute.copy_latest": "legutóbbi", + "note.reconstitute.held": [ + "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. ", + "Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot" + ], + "note.restore.failed": [ + "A visszaállítás sikertelen: " + ], + "note.restore.full_failed": [ + "A teljes visszaállítás sikertelen: " + ], + "note.restore.recover_failed": [ + "A helyreállítás sikertelen: " + ], + "note.restore.shares_failed": [ + "A megosztások visszaállítása sikertelen: " + ], + "note.restore.shares_recover_failed": [ + "A megosztások helyreállítása sikertelen: " + ], + "note.restore.unit_failed": [ + "Visszaállítás sikertelen: " + ], + "note.restore.file_failed": [ + "Fájl-visszaállítás sikertelen: " + ], + "note.restore.full_unit_failed": [ + "Teljes visszaállítás sikertelen: " + ], + "note.restore.recovered_files": [ + "A(z) ", + " hiányzó fájljai helyreállítva az élő adatok közé." + ], + "note.restore.shares_prepared": "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.", + "note.restore.at_scratch": " ellenőrző mappába", + "note.restore.at_scratch_named": [ + " ellenőrző mappába: " + ], + "note.restore.scratch_full": [ + "A(z) ", + " teljes mentése visszaállítva", + " — a saját fájljaiddal együtt. A meglévő adatok változatlanok." + ], + "note.restore.scratch_state": [ + "A(z) ", + " beállításai és adatbázisa visszaállítva", + ". 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." + ], + "note.restore.files_count": "%d fájl", + "note.restore.and_volumes": " és %d adatkötet", + "note.restore.and_database": " és az adatbázis", + "note.restore.volumes_count": "%d adatkötet", + "note.restore.the_database": "az adatbázis", + "note.restore.dumps_at": [ + " (mentés: ", + ")" + ], + "note.restore.reconstituted": "A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", + "note.restore.no_db_in_backup": " 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", + "note.restore.no_db_at_all": " Ennek az alkalmazásnak nincs adatbázisa.", + "note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.", + "note.restore.files_from_second": "%s: %d fájl visszaállítva a másodlagos másolatból.", + "note.restore.scratch_incomplete": "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.", + "note.unit_restore_data": "A(z) %s: %s visszaállítva — az alkalmazás újraindult.", + "note.unit_restore_settings_only": "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.", + "note.unit_restore_counts_unknown": "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.", + "note.unit_restore_none_returned": "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.", + "note.tier2_no_coverage": "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.", + "note.tier2_unit_available": "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.", + "note.tier2_unit_not_covered": "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba.", + "note.tier2_unit_restore_source": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s).", + "note.tier2_unit_restore_source_unproven": "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).", + "note.tier2_unit_action_label": "Teljes visszaállítás a másolatból", + "note.tier2_unit_confirm_base": "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.", + "note.tier2_unit_confirm_date_fmt": " A másolat kelte: %s.", + "note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.", + "note.tier2_unit_confirm_contrast": " 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.", + "note.tier2_unit_stale_clause": " 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.", + "note.tier2_unit_stale_notice_fmt": "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." }