R-516: the formal forms in Go literals move into the bundle in the te-form
gates / gates (push) Successful in 50s

22 Go-literal messages (escrow handler, share password page, export
upload, network-storage uid/remove/attach failures) are bundle keys with
te-form Hungarian and English; a detached attach failure renders in the
reader's language. The NAS refusal says „Válassz". Setup wizard,
recovery-info.txt (R-554) and the SMART/fill-watch wire texts are left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 21:40:02 +02:00
parent f65ace0ca6
commit 3d6ba2852f
14 changed files with 254 additions and 51 deletions
+6 -6
View File
@@ -181,13 +181,13 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
// this state; a legacy-open box must claim/set a password first).
hash := s.effectivePasswordHash()
if hash == "" {
escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "api.escrow.dashboard_password_missing"))
return
}
ip := rateKey(r)
if s.escrowRateLimited(ip) {
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
escrowJSON(w, http.StatusTooManyRequests, nil, s.msg(r, "api.escrow.too_many_attempts"))
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil {
@@ -212,7 +212,7 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil && s.backupMgr.OffboxConfigured() {
if err := s.escrowStage(r.Context()); err != nil {
s.logger.Printf("[WARN] [web] escrow start: re-stage failed (ceremony NOT started): %v", err) // err carries no secret
escrowJSON(w, http.StatusBadGateway, nil, "A távoli mentés jelszavának letéti előkészítése nem sikerült — a folyamat nem indult el. Próbálja újra.")
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.stage_failed"))
return
}
}
@@ -232,7 +232,7 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
resp, status, err := agent.EscrowCeremonyStart(r.Context())
if err != nil {
if status == http.StatusConflict {
escrowJSON(w, http.StatusConflict, nil, "Egy kódkészítés már folyamatban van — várja meg, míg befejeződik.")
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "api.escrow.already_running"))
return
}
s.logger.Printf("[WARN] [web] escrow start: agent trigger: %v", err)
@@ -277,11 +277,11 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
switch status {
case http.StatusGone: // 410 — the code was minted but never shown; permanently void.
escrowJSON(w, http.StatusGone, nil, "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsa újra a folyamatot: az új kód a régit érvényteleníti.")
escrowJSON(w, http.StatusGone, nil, s.msg(r, "api.escrow.code_not_shown"))
case http.StatusNotFound: // 404 — no ceremony has run (e.g. phase:none post-reboot). F-C:
// this used to fall through to a 502; a bad-gateway class code for "nothing to claim"
// is wrong. Relay a clean, honest 4xx.
escrowJSON(w, http.StatusNotFound, nil, "Nincs aktív helyreállítási folyamat — előbb indítsa el a kódkészítést.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "api.escrow.no_active_job"))
case http.StatusConflict: // 409 — the ceremony state doesn't allow a claim right now. F-C.
escrowJSON(w, http.StatusConflict, nil, "A folyamat jelenlegi állapotában a kód nem kérhető le.")
default: // status 0 (agent unreachable / transport error) or a genuine agent 5xx — a real
@@ -319,7 +319,7 @@ func (s *Server) apiUploadFinalize(w http.ResponseWriter, r *http.Request) {
logx.Warnf(s.logger, "[web] fab upload finalize size mismatch: got %d, declared %d — part deleted",
job.received, job.declared)
uploadJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
"ok": false, "error": "A feltöltött méret nem egyezik — próbálja újra.",
"ok": false, "error": s.msg(r, "api.export.upload_size_mismatch"),
})
return
}
@@ -3,7 +3,6 @@ package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
@@ -35,8 +34,8 @@ const (
// validMappedID reports whether id is a valid container uid/gid (F4 range check).
func validMappedID(id int) bool { return id >= mappedIDMin && id <= mappedIDMax }
// netAddUIDRangeMsg is the friendly F4 refusal for an out-of-range mapped uid/gid.
const netAddUIDRangeMsg = "Az alkalmazás felhasználói azonosítója (uid) érvénytelen. Adjon meg 1 és 65533 közötti értéket — a legtöbb médiaalkalmazás az 1000-est használja."
// netAddUIDRangeKey is the friendly F4 refusal for an out-of-range mapped uid/gid (bundle key, R-516).
const netAddUIDRangeKey = "api.netstorage.uid_range"
// netAddOutdatedMsg is the sync add-time refusal (machine code "agent_outdated") when the agent
// predates the coupled verify-before-commit add semantics (pre-v0.81.0).
@@ -130,7 +129,7 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
// here with a friendly Hungarian 400 — nothing is installed.
if !validMappedID(uid) || !validMappedID(gid) {
logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: uid/gid out of range (uid=%d gid=%d)", name, uid, gid)
writeDiskJSON(w, http.StatusBadRequest, false, netAddUIDRangeMsg, nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, netAddUIDRangeKey), nil)
return
}
@@ -179,7 +178,7 @@ func (s *Server) handleNetStorageAddStatus(w http.ResponseWriter, r *http.Reques
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"phase": job.Phase, "name": job.Name, "category": job.Category,
"message": job.Message, "detail": trimNetDetail(job.Detail), "warn": job.Warn,
"message": s.netAddJobMessage(r, job), "detail": trimNetDetail(job.Detail), "warn": job.Warn,
"path": job.Path, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
})
}
@@ -306,9 +305,7 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
// files deleted anyway → an unreapable autofs mount until host reboot — the C6B-F2 orphan).
if apps := s.deployedAppsOnPath(where); len(apps) > 0 {
s.logger.Printf("[WARN] [web] netstorage remove %q refused: deployed app(s) on the share: %s", name, strings.Join(apps, ", "))
writeDiskJSON(w, http.StatusConflict, false, fmt.Sprintf(
"A tároló nem távolítható el, amíg alkalmazás használja: %s. Előbb távolítsa el vagy költöztesse át az alkalmazást.",
strings.Join(apps, ", ")), nil)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "api.netstorage.remove_in_use", strings.Join(apps, ", ")), nil)
return
}
agent, err := s.netAgentForAdd()
+34 -29
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
@@ -41,13 +42,17 @@ type netAgent interface {
// netAddJob is the poll-visible orchestration state (GET /api/storage/netstorage/add/status).
type netAddJob struct {
Name string `json:"name"`
Phase string `json:"phase"` // agent_add | verifying | probing | registering | done | failed
Category string `json:"category,omitempty"` // failure category (agent classifier vocabulary + not_writable/probe_io)
Message string `json:"message,omitempty"` // the category's Hungarian customer message (§3.2)
Detail string `json:"detail,omitempty"` // raw English/journal detail (the collapsible)
Warn string `json:"warn,omitempty"` // non-fatal note on a successful add (e.g. probe cleanup)
Path string `json:"path,omitempty"` // the registered in-guest path (done only)
Name string `json:"name"`
Phase string `json:"phase"` // agent_add | verifying | probing | registering | done | failed
Category string `json:"category,omitempty"` // failure category (agent classifier vocabulary + not_writable/probe_io)
Message string `json:"message,omitempty"` // the category's Hungarian customer message (§3.2)
Detail string `json:"detail,omitempty"` // raw English/journal detail (the collapsible)
Warn string `json:"warn,omitempty"` // non-fatal note on a successful add (e.g. probe cleanup)
Path string `json:"path,omitempty"` // the registered in-guest path (done only)
// server and mappedUID (R-516) parameterize the failure message, which the status handler renders
// in the reader's language.
server string
mappedUID int
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -183,7 +188,8 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
fail := func(category, detail string) {
job.Phase = netAddPhaseFailed
job.Category = category
job.Message = netAddMessage(category, req.Server, req.MappedUID)
job.server, job.mappedUID = req.Server, req.MappedUID
job.Message = s.netAddMessage(s.boxLang(), category, req.Server, req.MappedUID)
job.Detail = detail
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
@@ -321,41 +327,40 @@ func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID stri
}
}
// netAddMessage maps a failure category to the EXACT Hungarian customer message (§3.2). server and
// mappedUID parameterize the unreachable/not_writable texts (host id = mapped uid + 100000).
func netAddMessage(category, server string, mappedUID int) string {
// netAddMessage maps a failure category to the customer message (§3.2) in lang. server and mappedUID
// parameterize the unreachable/not_writable texts (host id = mapped uid + 100000). R-516: the texts
// live in the bundle (api.netstorage.add.*), in the te-form, in both languages.
func (s *Server) netAddMessage(lang, category, server string, mappedUID int) string {
switch category {
case "unreachable":
msg := "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
msg := s.msgLang(lang, "api.netstorage.add.unreachable", server)
// R-66 Leg C: a single-label non-IP server (»FELHOM«) is almost always a Windows/NetBIOS
// network name, which this box generally cannot resolve — name the trap instead of letting
// the generic text teach nothing. Purely lexical on the submitted value: NO NetBIOS/mDNS
// resolution is ever attempted, and an IP or dotted DNS name never gets nagged about this.
if looksLikeFlatNetworkName(server) {
msg += " Tipp: a(z) »" + server + "« Windows-hálózati névnek tűnik — használja az eszköz IP-címét."
msg += s.msgLang(lang, "api.netstorage.add.flat_name_tip", server)
}
return msg
case "nfs_export":
return "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
case "smb_auth":
return "Hibás SMB felhasználónév vagy jelszó."
case "smb_share":
return "A megadott SMB-megosztás nem található a szerveren. Ellenőrizze a megosztás nevét."
case "timeout":
return "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizze a NAS NFS/SMB szolgáltatását."
case "nfs_export", "smb_auth", "smb_share", "timeout", "not_network_fs", "probe_io", "busy":
return s.msgLang(lang, "api.netstorage.add."+category)
case "not_writable":
return "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsolja be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsa a fájlok tulajdonosát a(z) " + fmt.Sprint(mappedUID+100000) + " azonosítóra. SMB esetén ellenőrizze, hogy a felhasználónak írási joga van a megosztáson."
case "not_network_fs":
return "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbálja újra a csatlakoztatást; ha a hiba ismétlődik, jelezze az üzemeltetőnek."
case "probe_io":
return "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizze a megosztást és a hálózatot."
case "busy":
return "Már folyamatban van egy csatlakoztatás. Várja meg, amíg befejeződik."
return s.msgLang(lang, "api.netstorage.add.not_writable", mappedUID+100000)
default: // mount_failed + agent_error + register_failed + any future agent code
return "A csatolás sikertelen. Részletek alább."
return s.msgLang(lang, "api.netstorage.add.failed")
}
}
// netAddJobMessage renders a failed job's message in the READER's language (the job ran detached,
// with no request; it keeps the category and its parameters). A job with no category passes its own
// Message through.
func (s *Server) netAddJobMessage(r *http.Request, job *netAddJob) string {
if job.Category == "" {
return job.Message
}
return s.netAddMessage(s.langFor(r), job.Category, job.server, job.mappedUID)
}
// looksLikeFlatNetworkName reports whether a submitted server value is a single-label non-IP name
// (no dot, not an IP literal) — the NetBIOS-name shape. `nas.local` (dotted) and any parseable IP
// (v4 or v6 — colons carry the v6 case through ParseIP) are NOT flagged: the hint must never nag
@@ -176,7 +176,7 @@ func TestNetAdd_AgentVerifyFailed_MappedMessage(t *testing.T) {
t.Fatalf("phase/category = %s/%s, want failed/nfs_export", job.Phase, job.Category)
}
// The EXACT §3.2 merged message (NFSv4 cannot distinguish not-found from not-permitted).
want := "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
want := "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizd az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
if job.Message != want {
t.Errorf("nfs_export message:\n got %q\nwant %q", job.Message, want)
}
@@ -152,6 +152,8 @@ func TestDebugDumpNetworkSection(t *testing.T) {
// R-66 Leg C — the NetBIOS trap named on an unreachable-class add failure.
func TestNetAddMessageNetBIOSHint(t *testing.T) {
s := testServer(t)
netAddMessage := func(c, srv string, uid int) string { return s.netAddMessage("hu", c, srv, uid) }
const hintMark = "Windows-hálózati névnek tűnik"
// C1: single-label non-IP name + unreachable → hint present, naming the submitted value.
@@ -0,0 +1,131 @@
package web
import (
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
var goStringLit = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
// R-516 (2026-10-06 night) — the formal („ön") forms that lived in GO LITERALS, where the bundle gate
// cannot read them: the escrow and share handlers, the export upload, the network-storage attach and
// remove errors, the uid refusal. They are bundle keys now, te-form in Hungarian, with English.
//
// Left on purpose (named so the scan's exemptions are not a wish): internal/setup and
// internal/recovery (the setup wizard and the household's recovery-info.txt — R-554, the operator's
// call); internal/notify and internal/fillwatch (the SMART and fill-watch texts are the hub event's
// MESSAGE — wire text the hub mails as is; converting them needs the household-copy producer shape).
//
// COMPANION RED-PROOF (observed): put back „próbálja újra" in share_handlers.go → the source scan fails
// naming the file and the stem. Restored.
func TestR516_GoLiteralFormalFormsAreGone(t *testing.T) {
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"api.escrow.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva.",
"api.escrow.no_active_job": "Nincs aktív helyreállítási folyamat — előbb indítsd el a kódkészítést.",
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
"api.export.upload_size_mismatch": "A feltöltött méret nem egyezik — próbáld újra.",
"api.netstorage.add.smb_share": "A megadott SMB-megosztás nem található a szerveren. Ellenőrizd a megosztás nevét.",
"api.netstorage.add.busy": "Már folyamatban van egy csatlakoztatás. Várd meg, amíg befejeződik.",
"api.netstorage.uid_range": "Az alkalmazás felhasználói azonosítója (uid) érvénytelen. Adj meg 1 és 65533 közötti értéket — a legtöbb médiaalkalmazás az 1000-est használja.",
"api.netstorage.add.not_network_fs": "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbáld újra a csatlakoztatást; ha a hiba ismétlődik, jelezd az üzemeltetőnek.",
}
for key, w := range want {
if got, fellBack, ok := b.Text("hu", key); !ok || fellBack || got != w {
t.Errorf("hu %s: %q, want %q", key, got, w)
}
if _, fellBack, ok := b.Text("en", key); !ok || fellBack {
t.Errorf("en %s: missing (fell back to Hungarian)", key)
}
}
// Source scan over the Go literals this row converted. ASCII-folded, so an accent cannot hide one.
fold := strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u")
stems := []string{"probalja", "toltse ujra", "allitson be", "varja meg", "inditsa", "ellenorizze",
"hasznalja az eszkoz", "kapcsolja be", "allitsa a", "jelezze az", "tavolitsa el vagy", "adjon meg", "valasszon csatlakoztatott"}
skip := map[string]bool{"setup": true, "recovery": true, "notify": true, "fillwatch": true}
root := filepath.Join("..")
scanned := 0
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if skip[info.Name()] || info.Name() == "testdata" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
data, rerr := os.ReadFile(p)
if rerr != nil {
return rerr
}
scanned++
for i, line := range strings.Split(string(data), "\n") {
tl := strings.TrimSpace(line)
if strings.HasPrefix(tl, "//") || !strings.Contains(tl, `"`) {
continue
}
// Only literals with a space are sentences; a bundle KEY (no space) may carry a stem in its name.
var sentences []string
for _, m := range goStringLit.FindAllString(tl, -1) {
if strings.Contains(m, " ") {
sentences = append(sentences, m)
}
}
lv := fold.Replace(strings.ToLower(strings.Join(sentences, " ")))
for _, st := range stems {
if strings.Contains(lv, st) {
t.Errorf("%s:%d still carries the formal %q: %s", p, i+1, st, tl)
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if scanned < 50 {
t.Fatalf("the scan read only %d Go files — it judged nothing", scanned)
}
// Positive control: the fold turns a formal literal into a matching stem.
if lv := fold.Replace(strings.ToLower(`"Próbálja újra."`)); !strings.Contains(lv, "probalja") {
t.Fatal("the ASCII fold does not work — the scan proves nothing")
}
}
// A detached attach job's failure is rendered in the READER's language, not frozen in the language of
// the moment it failed.
//
// COMPANION RED-PROOF (observed): make handleNetStorageAddStatus return job.Message again → the English
// reader gets the Hungarian sentence and this fails. Restored.
func TestR516_NetAddFailureFollowsTheReader(t *testing.T) {
s := testServer(t)
job := &netAddJob{Name: "media", Phase: netAddPhaseFailed, Category: "unreachable", server: "FELHOM", mappedUID: 1000}
job.Message = s.netAddMessage("hu", job.Category, job.server, job.mappedUID)
s.netAdd.set(job)
rr := httptest.NewRecorder()
s.handleNetStorageAddStatus(rr, httptest.NewRequest("GET", "/api/storage/network/add/status?lang=en", nil))
body := rr.Body.String()
if !strings.Contains(body, "The server cannot be reached (FELHOM)") || !strings.Contains(body, "looks like a Windows network name") {
t.Fatalf("an English reader must get the English failure with its tip: %s", body)
}
rr = httptest.NewRecorder()
s.handleNetStorageAddStatus(rr, httptest.NewRequest("GET", "/api/storage/network/add/status?lang=hu", nil))
if !strings.Contains(rr.Body.String(), "Ellenőrizd az IP-címet") {
t.Fatalf("a Hungarian reader must get the te-form sentence: %s", rr.Body.String())
}
}
+2 -2
View File
@@ -110,13 +110,13 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques
}
_ = r.ParseForm()
if !s.validShareCSRF(r) {
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
s.renderSharePasswordPage(w, r, s.msg(r, "api.share.form_invalid"))
return
}
ip := rateKey(r)
if s.shareRateLimited(ip) {
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
s.renderSharePasswordPage(w, r, s.msg(r, "api.share.too_many_attempts"))
return
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {