Files
felhom-controller/controller/internal/web/i18n_web.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00

488 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── i18n (v0.247.0) — the dashboard in more than one language ──────────────────────────────────
//
// Design: felhom.eu/documentation/architecture/10-localisation.md. Hungarian is the default and the
// only language a household sees unless it switches. The Hungarian page is byte-identical to what it
// was before this file existed — pinned by i18n_parity_test.go against fixtures captured from the
// unconverted templates.
// ── Go-side copy (v0.252.0, slice 2 — R-557) ───────────────────────────────────────────────────
//
// A handler builds sentences the template never sees: flash lines, page data, JSON answers. Those go
// through these three helpers, never through a Hungarian literal, so they follow the request's
// language exactly as the template does.
//
// The Hungarian text is the SAME BYTES the literal carried, which scripts/i18n_go_parity.py measures
// against a frozen capture of the base commit. So a handler that reads
//
// data["Msg"] = s.msg(r, "backup.window_updated")
//
// renders, for a Hungarian household, the literal that used to sit at that line and nothing else.
// bundle returns the message bundle for these helpers.
//
// It falls back to the process-wide i18n.Shared() when the Server has none. That is not defensive
// clutter: `s.i18n` is set by loadTemplates, so any Server built WITHOUT going through it — a test
// fixture, a future construction path — would otherwise render raw KEYS onto a page, which is the one
// outcome §4 of the localisation design forbids ("never a key, never a blank"). The bundle is
// embedded in the binary, so the fallback cannot fail for any reason a running box can reach; if it
// somehow does, the caller still gets the key rather than a panic.
func (s *Server) bundle() *i18n.Bundle {
if s.i18n != nil {
return s.i18n
}
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a Go-side message in the request's language, with the message's own printf verbs
// filled in from a.
func (s *Server) msg(r *http.Request, key string, a ...interface{}) string {
return s.msgLang(s.langFor(r), key, a...)
}
// msgLang is msg for a language that is already known — a background run reading the box's setting
// (internal/settings GetLanguage), or a handler that resolved the language once for several lines.
func (s *Server) msgLang(lang, key string, a ...interface{}) string {
b := s.bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// msgN returns a count-dependent message in the request's language. Hungarian carries one form under
// the key itself; English carries key+".one" and key+".other" (i18n.Bundle.Plural).
func (s *Server) msgN(r *http.Request, key string, n int) string {
b := s.bundle()
if b == nil {
return key
}
return b.Plural(s.langFor(r), key, n)
}
// ── Errors on a page (v0.253.0, slice 2 release B) ─────────────────────────────────────────────
//
// An error is MADE deep in a package that has no request, and PRINTED by a handler that has one.
// util.MsgError carries the key across that gap; errText is the handler side of it.
//
// A plain error — restic, docker, ssh, the Go stdlib — prints verbatim, in both languages, because
// that sentence is not ours (10-localisation.md §9). So this is safe to put in front of EVERY
// err.Error() on a display path, converted or not, and that is exactly what release B does.
// errText renders an error in the request's language: its bundle message when it carries one, its own
// text otherwise.
func (s *Server) errText(r *http.Request, err error) string {
return util.ErrText(s.langFor(r), err)
}
// errTextLang is errText for a language already resolved (a background run, or a handler that
// resolved it once).
func (s *Server) errTextLang(lang string, err error) string {
return util.ErrText(lang, err)
}
// ── Flash lines (v0.252.0) ─────────────────────────────────────────────────────────────────────
//
// A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT
// request from the one that wrote it — and until now it travelled as Hungarian text, which is the
// language of whoever redirected. It now travels as a bundle KEY plus its parameters, and the reader
// renders it in its own language.
//
// Backward compatibility is not a nicety here: a customer's open tab, a bookmark or a browser's
// back-forward cache can replay a URL minted by the previous version, and a mail client can carry
// one. So the rule is: a value the bundle knows as a key is a MESSAGE; anything else is TEXT and is
// shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=<script>` — which
// html/template escapes, as it always did.
// flashForErr turns an error into what a flash carries: its bundle KEY plus its parameters when it
// has one, and its own text otherwise.
//
// The second half is what makes this safe to use everywhere: a restic, docker or ssh sentence goes
// into the URL as text and is shown verbatim by flashText, exactly as it was before v0.252.0.
func flashForErr(err error) (string, []string) {
if err == nil {
return "", nil
}
if m, ok := util.AsMsg(err); ok {
return m.Key(), m.Args()
}
return err.Error(), nil
}
// flashArgParam is the repeated query parameter carrying a flash message's parameters, in order.
const flashArgParam = "fa"
// flashQuery builds the query fragment for a flash: the key, then one `fa` per parameter.
// It returns "flash=…" (or "flash_error=…") WITHOUT a leading separator, because the callers differ
// on whether the destination already carries a query.
func flashQuery(param, key string, args ...string) string {
q := url.Values{}
q.Set(param, key)
for _, a := range args {
q.Add(flashArgParam, a)
}
return q.Encode()
}
// flashText resolves a flash query value for display.
//
// A value that names a key in the bundle is rendered in lang, with the `fa` parameters filled in.
// Anything else — a sentence minted by an older controller, or something a person typed — is
// returned unchanged. It is never dropped and never shown as a raw key.
func (s *Server) flashText(r *http.Request, v string) string {
if v == "" {
return ""
}
b := s.bundle()
if b == nil {
return v
}
lang := s.langFor(r)
if !b.Has(i18n.Default, v) {
return v // legacy text, or not ours: verbatim
}
var args []interface{}
if r != nil {
for _, a := range r.URL.Query()[flashArgParam] {
args = append(args, a)
}
}
if len(args) == 0 {
return b.Msg(lang, v)
}
return b.Msgf(lang, v, args...)
}
// flashFrom reads one flash parameter off the request and resolves it. Empty when absent.
func (s *Server) flashFrom(r *http.Request, param string) string {
if r == nil {
return ""
}
return s.flashText(r, strings.TrimSpace(r.URL.Query().Get(param)))
}
// addLanguageData puts the request's language and the switch state into a page's data.
//
// The switch is on every dashboard page, in every language (v0.250.0, slice 1 release C). Until then it
// was hidden from Hungarian pages without ?lang=, because English covered only part of the dashboard;
// every template is converted now, so the offer no longer leads to a half-English page. Pinned by
// TestLanguageSwitch_EndToEnd (a Hungarian household with no ?lang= sees the form).
func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, lang string) {
data["Lang"] = lang
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
// is known. Idempotent: an Alert keeps its key, so rendering it twice is rendering it once.
if alerts, ok := data["Alerts"].([]Alert); ok {
for i := range alerts {
alerts[i] = alerts[i].rendered(lang)
}
}
// A page title is Go-side copy. The handler names its key; the Hungarian title the handler set is
// left untouched, so a Hungarian page cannot change here.
//
// TitleArgs (v0.252.0, R-566) carries the app name for the three titles built around one: „<app>
// — Naplók", „<app> — Telepítés"/„— Beállítások" and „2. mentés beállítása — <app>". Their page
// BODIES were English from slice 1 while the browser tab stayed Hungarian, because one static
// message cannot hold a name. The message now carries a `%s` and the handler supplies the name.
if key, ok := data["TitleKey"].(string); ok && lang != i18n.Default && s.i18n != nil {
if args, ok := data["TitleArgs"].([]interface{}); ok && len(args) > 0 {
data["Title"] = s.i18n.Msgf(lang, key, args...)
} else {
data["Title"] = s.i18n.Msg(lang, key)
}
}
}
// 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}}.
func (s *Server) languageSwitchHandler(w http.ResponseWriter, r *http.Request) {
lang := r.FormValue("lang")
if !i18n.IsSupported(lang) {
http.Error(w, "unsupported language", http.StatusBadRequest)
return
}
if err := s.settings.SetLanguage(lang); err != nil {
s.logger.Printf("[ERROR] [web] language switch: save failed: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] [web] language switch: household language set to %s", lang)
s.reportTriggerNow() // the hub learns the language on the next report, in seconds rather than minutes
http.Redirect(w, r, stripLangQuery(redirectBackTo(r, "/launcher")), http.StatusFound)
}
// stripLangQuery keeps a ?lang= override from pinning the page after the setting has been saved.
func stripLangQuery(p string) string {
if i := strings.Index(p, "?"); i >= 0 {
return p[:i]
}
return p
}
// localeFuncs returns the template funcs whose OUTPUT is copy, re-implemented from the bundle, for
// every language except Hungarian. Hungarian gets nothing here: its funcs stay the ones in
// templateFuncMap, untouched, which is the parity guarantee for Go-side helper text.
// TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json carries the same words the Hungarian
// funcs return, so the bundle can become the single source in slice 1 without a byte moving.
func (s *Server) localeFuncs(lang string) template.FuncMap {
if lang == i18n.Default {
return nil
}
b, err := i18n.Shared()
if err != nil {
return nil
}
loc := getTimezone()
ago := func(d time.Duration) string {
switch {
case d < time.Minute:
return b.Msg(lang, "func.time.now")
case d < time.Hour:
return b.Plural(lang, "func.time.minutes_ago", int(d.Minutes()))
case d < 24*time.Hour:
return b.Plural(lang, "func.time.hours_ago", int(d.Hours()))
case d < 48*time.Hour:
return b.Msg(lang, "func.time.yesterday")
default:
return b.Plural(lang, "func.time.days_ago", int(d.Hours()/24))
}
}
return template.FuncMap{
"stateLabel": func(state stacks.ContainerState) string {
return b.Msg(lang, stateLabelKey(state))
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return "–"
}
return ago(time.Now().In(loc).Sub(t.In(loc)))
},
"timeAgoStr": func(str string) string {
t, err := time.Parse(time.RFC3339, str)
if err != nil {
return str
}
return ago(time.Now().In(loc).Sub(t.In(loc)))
},
"nextRunLabel": func(t time.Time) string {
if t.IsZero() {
return "–"
}
lt := t.In(loc)
now := time.Now().In(loc)
hm := lt.Format("15:04")
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return fmt.Sprintf(b.Msg(lang, "func.time.today_at"), hm)
}
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
return fmt.Sprintf(b.Msg(lang, "func.time.tomorrow_at"), hm)
}
return lt.Format("2006-01-02") + " " + hm
},
// infraMeta: the protected infra stacks' curated identity (inframeta.go). Names that are product
// names stay; descriptions — and samba's display name — come from the bundle.
"infraMeta": func(name string) *InfraMeta {
m := infraMetaFor(name)
if m == nil {
return nil
}
out := *m
if v, _, ok := b.Text(lang, "func.infra."+name+".description"); ok {
out.Description = v
}
if v, _, ok := b.Text(lang, "func.infra."+name+".display"); ok {
out.DisplayName = v
}
return &out
},
"statusText": func(status string) string {
switch status {
case "pending", "restoring", "done", "failed", "skipped":
return b.Msg(lang, "func.restore_status."+status)
default:
return status
}
},
}
}
// stateLabelKey maps a container state to its bundle key. The default arm mirrors templateFuncMap's
// "Ismeretlen" default, so an unmapped state is labelled unknown in every language.
func stateLabelKey(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "func.state.running"
case stacks.StateStarting:
return "func.state.starting"
case stacks.StateDeploying:
return "func.state.deploying"
case stacks.StateUnhealthy:
return "func.state.unhealthy"
case stacks.StateDegraded:
return "func.state.degraded"
case stacks.StateStopped, stacks.StateExited:
return "func.state.stopped"
case stacks.StateRestarting:
return "func.state.restarting"
case stacks.StateNotDeployed:
return "func.state.not_deployed"
case stacks.StatePaused:
return "func.state.paused"
default:
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) }