0054d4bd69
gates / gates (push) Successful in 27s
R-634: a whole-box backup no longer stops/restarts a DEPLOYING app (the measured cause of containers running under 'not deployed'); StopStack and StartStack refuse a deploying stack for every caller. R-625: held badge 'Stopped - restore needed', no Update button. R-636: kernel oom_kill counter; 20+ in 30 min -> one app_oom_storm. R-647: held error per reader, copy_holds key, two log wordings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
593 lines
24 KiB
Go
593 lines
24 KiB
Go
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))
|
||
},
|
||
// v0.264.0 (R-606): the update path in the reader's language. A stored sentence with no key (an
|
||
// older version's, or a composed one) renders as it is — never as an empty line.
|
||
"updatePhaseText": func(st interface{}) string {
|
||
stk := stackOf(st)
|
||
if l := stacks.UpdatePhaseLabelIn(lang, stk.UpdatePhase); l != "" && l != stacks.UpdatePhaseLabel(stk.UpdatePhase) {
|
||
return l
|
||
}
|
||
return stk.UpdatePhaseLabel // no translation for this phase: the stored label, never an empty line
|
||
},
|
||
"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
|
||
},
|
||
// updateBadge / lifecycleBadge (R-589, v0.258.0) — the two *MetaBadge builders. Their
|
||
// Hungarian forms in templateFuncMap are UNTOUCHED, which is the parity guarantee; these
|
||
// rebuild the same badge from the bundle, reusing compareInstalledToTemplate and
|
||
// EffectiveLifecycle so the DECISION cannot drift between the two languages — only the
|
||
// words do. TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json says what the
|
||
// Hungarian funcs say.
|
||
//
|
||
// Found live on 2026-09-20: on an English app page the only Felhom-authored Hungarian left
|
||
// was this badge, „Naprakész", with a Hungarian tooltip under it.
|
||
"updateBadge": func(st stacks.Stack) *MetaBadge {
|
||
if st.HoldReason != "" { // R-625's twin — see updatebadge.go
|
||
return &MetaBadge{
|
||
Label: b.Msg(lang, "badge.update.held"),
|
||
Class: "tag-error",
|
||
Title: firstSentence(st.HoldReason),
|
||
}
|
||
}
|
||
switch compareInstalledToTemplate(st) {
|
||
case updateCurrent:
|
||
return &MetaBadge{
|
||
Label: b.Msg(lang, "badge.update.current"),
|
||
Class: "tag-ok",
|
||
Title: b.Msg(lang, "badge.update.current.title"),
|
||
}
|
||
case updateAhead:
|
||
// R-524's twin. Same word, same class as updateCurrent — see updatebadge.go.
|
||
return &MetaBadge{
|
||
Label: b.Msg(lang, "badge.update.current"),
|
||
Class: "tag-ok",
|
||
Title: b.Msg(lang, "badge.update.ahead.title"),
|
||
}
|
||
case updateBehind:
|
||
label := b.Msg(lang, "badge.update.behind")
|
||
if days, ok := st.Meta.CatalogSinceAge(time.Now().UTC()); ok {
|
||
if days == 0 {
|
||
label += b.Msg(lang, "badge.update.behind.today")
|
||
} else {
|
||
label += b.Plural(lang, "badge.update.behind.days", days)
|
||
}
|
||
}
|
||
return &MetaBadge{Label: label, Class: "tag-warn", Title: b.Msg(lang, "badge.update.behind.title")}
|
||
default:
|
||
// UNKNOWN renders nothing, in every language — see updatebadge.go.
|
||
return nil
|
||
}
|
||
},
|
||
"lifecycleBadge": func(m stacks.Metadata) *MetaBadge {
|
||
if m.EffectiveLifecycle() != stacks.LifecycleAbandoned {
|
||
return nil
|
||
}
|
||
return &MetaBadge{
|
||
Label: b.Msg(lang, "badge.lifecycle.abandoned"),
|
||
Class: "tag-warn",
|
||
Title: b.Msg(lang, "badge.lifecycle.abandoned.title"),
|
||
}
|
||
},
|
||
"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) }
|
||
|
||
// readerFuncs are the funcs that need the SERVER to answer in the reader's language, and — unlike
|
||
// localeFuncs — they apply to EVERY language, Hungarian included (R-647, v0.265.0). A hold sentence is
|
||
// composed by the backup side in one language at a time; before this a Hungarian reader of an English
|
||
// box (the `?lang=` switch, an operator) read it in English. On a Hungarian box a Hungarian reader gets
|
||
// exactly the bytes templateFuncMap gave (the parity fixtures are rendered through here).
|
||
// `prev` is the func set they wrap (templateFuncMap + localeFuncs for this language).
|
||
func (s *Server) readerFuncs(lang string, prev template.FuncMap) template.FuncMap {
|
||
holdFor := func(st stacks.Stack) string {
|
||
if st.HoldReason == "" || s.backupMgr == nil {
|
||
return st.HoldReason
|
||
}
|
||
if held, why := s.backupMgr.RestoreHoldForLang(st.Name, lang); held && why != "" {
|
||
return why
|
||
}
|
||
return st.HoldReason
|
||
}
|
||
out := template.FuncMap{
|
||
"holdText": func(st interface{}) string { return holdFor(stackOf(st)) },
|
||
"updateErrorText": func(st interface{}) string {
|
||
stk := stackOf(st)
|
||
if stk.UpdateErrorKey == stacks.UpdateErrorKeyHeld && s.backupMgr != nil {
|
||
if held, why := s.backupMgr.RestoreHoldForLang(stk.Name, lang); held && why != "" {
|
||
return why
|
||
}
|
||
}
|
||
return stk.UpdateErrorIn(lang)
|
||
},
|
||
}
|
||
if base, ok := prev["updateBadge"].(func(stacks.Stack) *MetaBadge); ok {
|
||
out["updateBadge"] = func(st stacks.Stack) *MetaBadge {
|
||
bd := base(st)
|
||
if bd != nil && st.HoldReason != "" { // R-625: the title is the hold's opening, for THIS reader
|
||
bd.Title = firstSentence(holdFor(st))
|
||
}
|
||
return bd
|
||
}
|
||
}
|
||
return out
|
||
}
|