v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s

Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 10:15:56 +02:00
parent 736f54b496
commit 5270bad76e
52 changed files with 10755 additions and 478 deletions
+123 -70
View File
@@ -9,19 +9,59 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Alert represents a persistent dashboard alert banner.
//
// LOCALISATION (v0.252.0, R-557). An alert is BUILT by a background health cycle and READ minutes
// later by whichever page the household opens, so its text cannot be rendered where it is made —
// that is the same shape as a flash in a redirect URL, one layer in. An alert therefore carries a
// bundle KEY plus its parameters, and GetAlerts renders it in the language the reader asked for.
//
// Two deliberate exceptions, both because the text is NOT ours to translate:
//
// - the health report's Issues and Warnings, which arrive as finished sentences and go ON THE WIRE
// to the hub (report `health.warnings`, pinned by internal/monitor's wire golden). They keep
// `Message` and stay Hungarian in every language until slice 3 gives the hub a language;
// - the agent-channel and endpoint-drift lines, whose text is composed by the channel-health
// checker. Filed as R-573.
//
// An alert with no MessageKey renders `Message` verbatim, which is what makes both exceptions work
// without a second mechanism.
type Alert struct {
ID string // unique identifier for filtering
Level string // "error", "warning", "info"
Message string // Hungarian display text
Link string // optional link to relevant page
LinkText string // link display text
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
ID string // unique identifier for filtering
Level string // "error", "warning", "info"
Message string // display text; used verbatim when MessageKey is empty
MessageKey string // bundle key for Message (preferred); empty = Message is already text
MessageArgs []interface{} // the key's printf parameters, in order
Link string // optional link to relevant page
LinkText string // link display text; used verbatim when LinkTextKey is empty
LinkTextKey string // bundle key for LinkText
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
}
// rendered returns a copy of the alert with its keys resolved in lang. A key the bundle does not
// carry falls back to whatever Message/LinkText already held, so an alert can never render blank.
func (a Alert) rendered(lang string) Alert {
b, err := i18n.Shared()
if err != nil {
return a
}
if a.MessageKey != "" {
if len(a.MessageArgs) > 0 {
a.Message = b.Msgf(lang, a.MessageKey, a.MessageArgs...)
} else {
a.Message = b.Msg(lang, a.MessageKey)
}
}
if a.LinkTextKey != "" {
a.LinkText = b.Msg(lang, a.LinkTextKey)
}
return a
}
// AlertManager generates and stores dashboard alerts from health check results.
@@ -72,11 +112,11 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) {
return
}
am.agentChannelAlert = &Alert{
ID: "agent-channel-down",
Level: "error",
Message: msg,
Link: "/settings",
LinkText: "Beállítások",
ID: "agent-channel-down",
Level: "error",
Message: msg, // composed by the channel-health checker -- R-573
Link: "/settings",
LinkTextKey: "alert.link.settings",
}
}
@@ -94,11 +134,11 @@ func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) {
return
}
am.endpointDriftAlert = &Alert{
ID: "local-api-endpoint-drift",
Level: "error",
Message: msg,
Link: "/settings",
LinkText: "Beállítások",
ID: "local-api-endpoint-drift",
Level: "error",
Message: msg, // composed by the endpoint-drift checker -- R-573
Link: "/settings",
LinkTextKey: "alert.link.settings",
}
}
@@ -122,11 +162,12 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
}
if len(dead) > deadAppGroupThreshold {
return []Alert{{
ID: "deadapp-group",
Level: "warning",
Message: fmt.Sprintf("%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", len(dead)),
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "deadapp-group",
Level: "warning",
MessageKey: "alert.deadapp.group",
MessageArgs: []interface{}{len(dead)},
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
}}
}
alerts := make([]Alert, 0, len(dead))
@@ -135,16 +176,17 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
if name == "" {
name = d.Name
}
msg := "Telepített alkalmazás nem fut: " + name
key, args := "alert.deadapp.one", []interface{}{name}
if d.State != "" {
msg += " (" + d.State + ")"
key, args = "alert.deadapp.one_state", []interface{}{name, d.State}
}
alerts = append(alerts, Alert{
ID: "deadapp-" + simpleHash(d.Name),
Level: "warning",
Message: msg,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "deadapp-" + simpleHash(d.Name),
Level: "warning",
MessageKey: key,
MessageArgs: args,
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
return alerts
@@ -173,11 +215,12 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
label = sp.Path
}
alerts = append(alerts, Alert{
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
Message: fmt.Sprintf("Meghajtó leválasztva: %s (%s)", label, sp.Path),
Link: "/settings",
LinkText: "Beállítások",
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
MessageKey: "alert.storage.disconnected",
MessageArgs: []interface{}{label, sp.Path},
Link: "/settings",
LinkTextKey: "alert.link.settings",
})
}
}
@@ -186,22 +229,22 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// From health check issues (critical)
for _, issue := range report.Issues {
alerts = append(alerts, Alert{
ID: "health-" + simpleHash(issue),
Level: "error",
Message: issue,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "health-" + simpleHash(issue),
Level: "error",
Message: issue, // ON THE WIRE (report health.issues) -- not ours to translate; slice 3
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
// From health check warnings
for i, w := range report.Warnings {
alert := Alert{
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w, // ON THE WIRE (report health.warnings) -- not ours to translate; slice 3
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
}
// R-553 — WHERE this warning is shown is decided by its KIND, not by the Hungarian words it
// contains. The old test was `strings.Contains(w, "meghajtón"/"adattároló"/"meghajtó")`, which
@@ -220,21 +263,22 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// Hub connection status
if !cfg.Hub.Enabled || cfg.Hub.URL == "" {
alerts = append(alerts, Alert{
ID: "hub-disabled",
Level: "warning",
Message: "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "hub-disabled",
Level: "warning",
MessageKey: "alert.hub.disabled",
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
} else if am.hubPushStatusFn != nil {
ps := am.hubPushStatusFn()
if ps.LastError != "" && (ps.LastSuccess.IsZero() || time.Since(ps.LastSuccess) > 30*time.Minute) {
alerts = append(alerts, Alert{
ID: "hub-unreachable",
Level: "error",
Message: fmt.Sprintf("Hub nem elérhető — utolsó hiba: %s", ps.LastError),
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "hub-unreachable",
Level: "error",
MessageKey: "alert.hub.unreachable",
MessageArgs: []interface{}{ps.LastError},
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
}
@@ -242,22 +286,23 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// Backup disabled
if !cfg.Backup.Enabled {
alerts = append(alerts, Alert{
ID: "backup-disabled",
Level: "warning",
Message: "A biztonsági mentés nincs bekapcsolva",
Link: "/settings",
LinkText: "Beállítások",
ID: "backup-disabled",
Level: "warning",
MessageKey: "alert.backup.disabled",
Link: "/settings",
LinkTextKey: "alert.link.settings",
})
}
// Update available
if updateAvailable && latestVersion != "" {
alerts = append(alerts, Alert{
ID: "update-available",
Level: "info",
Message: fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion),
Link: "/settings",
LinkText: "Frissítés",
ID: "update-available",
Level: "info",
MessageKey: "alert.update.available",
MessageArgs: []interface{}{latestVersion},
Link: "/settings",
LinkTextKey: "alert.link.update",
})
}
@@ -270,7 +315,7 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
}
// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs.
func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -313,18 +358,23 @@ func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
overflow := len(result) - 5
result = result[:5]
result = append(result, Alert{
ID: "overflow",
Level: "info",
Message: fmt.Sprintf("+ %d további figyelmeztetés", overflow),
Link: "/monitoring",
ID: "overflow",
Level: "info",
MessageKey: "alert.overflow", MessageArgs: []interface{}{overflow},
Link: "/monitoring",
})
}
// Rendered LAST, once, on the way out: the alerts are stored as keys and only the reader knows
// the language. Every return path goes through here, so an alert cannot escape with a raw key.
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}
// GetInlineAlerts returns alerts marked as Inline for a specific page.
func (am *AlertManager) GetInlineAlerts(page string) []Alert {
func (am *AlertManager) GetInlineAlerts(page, lang string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -344,6 +394,9 @@ func (am *AlertManager) GetInlineAlerts(page string) []Alert {
}
}
}
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}
@@ -125,8 +125,8 @@ func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) {
if w.Code != http.StatusFound {
t.Fatalf("want 302, got %d", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "elindult") {
t.Fatalf("redirect should carry the 'elindult' flash; got %q", loc)
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elindult") {
t.Fatalf("redirect should carry the 'elindult' flash; got %q -> %q", loc, flashSentence(t, loc))
}
// the background restore is now parked in StopStack → op-status shows running.
waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running")
+1 -1
View File
@@ -97,7 +97,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
if r.URL.Path == "/login" {
s.renderLogin(w, r, "", r.URL.Query().Get("flash"))
s.renderLogin(w, r, "", s.flashFrom(r, "flash"))
return
}
if r.URL.Path == "/logout" {
+5 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"time"
@@ -42,12 +41,12 @@ func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request)
_ = r.ParseForm()
start := strings.TrimSpace(r.FormValue("window_start"))
if backupwindow.Valid(start) != nil {
s.backupWindowRedirect(w, r, "", "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).")
s.backupWindowRedirect(w, r, "", "flash.backup.window_invalid_time")
return
}
if err := s.settings.SetBackupWindowStart(start); err != nil {
s.logger.Printf("[ERROR] [web] backup window save failed: %v", err)
s.backupWindowRedirect(w, r, "", "A mentési időablak mentése nem sikerült.")
s.backupWindowRedirect(w, r, "", "flash.backup.window_save_failed")
return
}
// Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling
@@ -63,16 +62,16 @@ func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request)
s.backupMgr.RefreshCache(scheduler.NextDailyRun(db))
}
s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox)
s.backupWindowRedirect(w, r, "Mentési időablak frissítve.", "")
s.backupWindowRedirect(w, r, "flash.backup.window_updated", "")
}
// backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash.
func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) {
dest := "/backups"
if flashErr != "" {
dest += "?flash_error=" + url.QueryEscape(flashErr)
dest += "?" + flashQuery("flash_error", flashErr)
} else if flash != "" {
dest += "?flash=" + url.QueryEscape(flash)
dest += "?" + flashQuery("flash", flash)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
@@ -104,7 +104,7 @@ func TestBuildAppBackupRows_OffboxMapping(t *testing.T) {
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
{StackName: "radarr", DisplayName: "Radarr"},
}}
rows := s.buildAppBackupRows(status)
rows := s.buildAppBackupRows(status, "hu")
cal := findRow(rows, "calibre-web")
if cal == nil || !cal.OffboxEnabled || cal.Tier3State != "active" {
@@ -126,7 +126,7 @@ func TestBuildAppBackupRows_EscrowPendingPrecedence(t *testing.T) {
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
}})
}}, "hu")
cal := findRow(rows, "calibre-web")
if cal == nil || cal.Tier3State != "escrow_pending" {
t.Fatalf("escrow-pending must win over a prior ok run: %+v", cal)
@@ -158,7 +158,7 @@ func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) {
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "hasunit", DisplayName: "Has Unit"},
{StackName: "nounit", DisplayName: "No Unit"},
}})
}}, "hu")
hu := findRow(rows, "hasunit")
if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) {
@@ -14,7 +14,7 @@ func TestDeadAppAlerts_PresentAndSelfClearing(t *testing.T) {
am := NewAlertManager(log.New(io.Discard, "", 0))
am.SetDeadAppAlerts([]DeadApp{{Name: "cwa", DisplayName: "Calibre-Web", State: "stopped"}})
got := am.GetAlerts()
got := am.GetAlerts("hu")
if len(got) != 1 || got[0].Level != "warning" || !strings.Contains(got[0].Message, "Telepített alkalmazás nem fut: Calibre-Web") {
t.Fatalf("expected a WARN dead-app banner, got %+v", got)
}
@@ -24,7 +24,7 @@ func TestDeadAppAlerts_PresentAndSelfClearing(t *testing.T) {
// The app recovers → an empty set clears the banner (state-based, no manual dismissal).
am.SetDeadAppAlerts(nil)
if got := am.GetAlerts(); len(got) != 0 {
if got := am.GetAlerts("hu"); len(got) != 0 {
t.Fatalf("dead-app banner must self-clear when the app recovers, got %+v", got)
}
}
@@ -38,7 +38,7 @@ func TestDeadAppAlerts_GroupedAboveThreshold(t *testing.T) {
many = append(many, DeadApp{Name: n, DisplayName: n, State: "stopped"})
}
am.SetDeadAppAlerts(many)
got := am.GetAlerts()
got := am.GetAlerts("hu")
if len(got) != 1 || !strings.Contains(got[0].Message, "5 telepített alkalmazás nem fut") {
t.Fatalf("expected one grouped banner for %d dead apps, got %+v", len(many), got)
}
+1 -1
View File
@@ -352,7 +352,7 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
// Alerts
if s.alertManager != nil {
dump["alerts"] = s.alertManager.GetAlerts()
dump["alerts"] = s.alertManager.GetAlerts(s.langFor(r))
}
w.Header().Set("Content-Type", "application/json")
+110 -107
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -47,11 +48,11 @@ type StorageBarInfo struct {
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
// purpose text on the storage-management page (Phase 4C).
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
const storageBarPurposeKey = "storage.bar_purpose"
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
// (by path) with a purpose description.
func (s *Server) buildStorageBars() []StorageBarInfo {
func (s *Server) buildStorageBars(lang string) []StorageBarInfo {
var bars []StorageBarInfo
for _, sp := range s.settings.GetStoragePaths() {
// Skip decommissioned drives — they are no longer in active use
@@ -62,7 +63,7 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Purpose: s.msgLang(lang, storageBarPurposeKey),
Disconnected: true,
})
continue
@@ -74,7 +75,7 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Purpose: s.msgLang(lang, storageBarPurposeKey),
TotalGB: di.TotalGB,
UsedGB: di.UsedGB,
Percent: di.UsedPercent,
@@ -134,7 +135,9 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
"ClaimLegacyOpen": s.claimLegacyOpen(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
// Hungarian here; addLanguageData re-renders the set in the request's language, because
// baseData has no request and every page goes through executeTemplate (v0.252.0, R-557).
data["Alerts"] = s.alertManager.GetAlerts(i18n.Default)
}
return data
}
@@ -189,7 +192,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
// Disk-health card (v0.169.0) — physical-disk SMART verdicts via the 60s-TTL-cached /disks call.
// Never blocks the render: an unreachable agent yields nil rows and the card shows its empty state.
@@ -214,7 +217,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["Subdomains"] = s.subdomainMap(deployedStacks)
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard", s.langFor(r))
}
s.executeTemplate(w, r, "dashboard", data)
@@ -328,7 +331,7 @@ func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
data["ShareURL"] = "https://" + r.Host + "/s/" + token
}
data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != ""
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
if f := s.flashFrom(r, "flash"); f != "" {
data["ShareFlash"] = f
}
s.executeTemplate(w, r, "launcher", data)
@@ -400,7 +403,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
logs, err := s.stackMgr.GetLogs(name, 200)
if err != nil {
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
logs = s.msg(r, "logs.fetch_failed", err)
}
// Raw mode: return plain text for AJAX polling
@@ -411,6 +414,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
}
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["TitleKey"], data["TitleArgs"] = "page.title.logs", []interface{}{stack.Meta.DisplayName} // i18n: the Hungarian title above is what hu renders
data["Stack"] = stack
data["Logs"] = logs
s.executeTemplate(w, r, "logs", data)
@@ -432,11 +436,12 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
stack, _ := s.stackMgr.GetStack(name)
alreadyDeployed := appCfg != nil && appCfg.Deployed
pageTitle := meta.DisplayName + " — Telepítés"
pageTitle, pageTitleKey := meta.DisplayName+" — Telepítés", "page.title.deploy"
if alreadyDeployed {
pageTitle = meta.DisplayName + " — Beállítások"
pageTitle, pageTitleKey = meta.DisplayName+" — Beállítások", "page.title.app_settings"
}
data := s.baseData("deploy", pageTitle)
data["TitleKey"], data["TitleArgs"] = pageTitleKey, []interface{}{meta.DisplayName} // i18n: the Hungarian title above is what hu renders
data["Stack"] = stack
data["Meta"] = meta
data["AppConfig"] = appCfg
@@ -522,7 +527,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
// the honest UI over it, and it must not be mistaken for the boundary itself.
if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse {
dp.NotAllowed = true
dp.NotAllowedNote = "hálózati tárhely — alkalmazáshoz nem választható"
dp.NotAllowedNote = s.msg(r, "deploy.path_not_allowed")
}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
@@ -671,10 +676,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
// Flash messages from cross-drive backup save redirect
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
data["FlashSuccess"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
@@ -754,7 +759,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
case settings.FileBrowserAdminGenerated:
data["HasAppInfo"] = true
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin",
Note: "A Fájlkezelő belépése. A jelszót a Felhom állította be ezen a gépen."}
Note: s.msg(r, "creds.filebrowser_note")}
data["InitialCredsHasPassword"] = enc != ""
case settings.FileBrowserAdminOperator:
data["HasAppInfo"] = true
@@ -797,11 +802,11 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
data["TitleKey"] = "page.title.monitoring" // i18n: the Hungarian title above is what hu renders
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
data["Alerts"] = s.alertManager.GetAlerts(s.langFor(r))
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring", s.langFor(r))
}
// Hub connection status section
@@ -824,11 +829,11 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
if s.cfg.Monitoring.Enabled {
pings := []map[string]interface{}{
{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": monitoringIntegritySchedule},
{"Label": s.msg(r, "ping.label.heartbeat"), "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": s.msg(r, "ping.schedule.5min")},
{"Label": s.msg(r, "ping.label.system_health"), "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": s.msg(r, "ping.schedule.5min")},
{"Label": s.msg(r, "ping.label.db_dump"), "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": s.msg(r, "ping.schedule.daily_at", s.cfg.Backup.DBDumpSchedule)},
{"Label": s.msg(r, "ping.label.backup"), "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": s.msg(r, "ping.schedule.daily_at", s.cfg.Backup.ResticSchedule)},
{"Label": s.msg(r, "ping.label.integrity"), "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": monitoringIntegritySchedule},
}
allConfigured := true
for _, p := range pings {
@@ -863,10 +868,10 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
fullStatus.FlashSuccess = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
fullStatus.FlashError = flashErr
}
data["Backup"] = fullStatus
@@ -875,7 +880,7 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri
// backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore
// pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify).
func (s *Server) backupsOffboxData(data map[string]interface{}) {
func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
offboxTgt := s.settings.GetOffboxTarget()
data["Offbox"] = offboxTgt
data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured()
@@ -897,7 +902,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
data["OffboxToggledCount"] = offboxToggled
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
if offboxTgt != nil {
data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxTgt.LastWarningKind, offboxToggled)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(offboxTgt.LastWarning, offboxTgt.LastWarningKind, offboxToggled, lang)
} else {
data["OffboxWarningDisplay"] = ""
}
@@ -917,8 +922,8 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
// v0.251.0, when the run started recording a KIND beside it (R-553). It is kept ONLY to read boxes
// upgraded with that older text already persisted — see offboxWarningDisplay.
const (
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza."
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
offboxSelectionChangedKey = "offbox.selection_changed"
)
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
@@ -934,15 +939,15 @@ const (
// R-553 legacy: remove after every fleet box has completed one off-site run on ≥ 0.251.0 (row R-570).
// Localisation slice 2 (R-557) must not translate the producer at backup/offbox.go until that row is
// closed — translating it while this fallback is still needed would strand exactly those boxes.
func offboxWarningDisplay(lastWarning, kind string, toggledCount int) string {
func (s *Server) offboxWarningDisplay(lastWarning, kind string, toggledCount int, lang string) string {
if toggledCount < 1 {
return lastWarning
}
if kind == backup.OffboxWarnNoAppsSelected {
return offboxSelectionChangedLine
return s.msgLang(lang, offboxSelectionChangedKey)
}
if kind == "" && strings.Contains(lastWarning, offboxStaleWarningMarker) {
return offboxSelectionChangedLine
return s.msgLang(lang, offboxSelectionChangedKey)
}
return lastWarning
}
@@ -955,7 +960,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
// System info for storage overview bars
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
data["GuestBackup"] = s.loadGuestBackup(r.Context())
@@ -1007,7 +1012,7 @@ func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool)
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r)
data["TitleKey"] = "page.title.backups_remote" // i18n: the Hungarian title above is what hu renders
s.backupsOffboxData(data)
s.backupsOffboxData(data, s.langFor(r))
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
data["EscrowStale"] = s.escrowStale()
agentVer := ""
@@ -1055,7 +1060,7 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r)
data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders
s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status
s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
// Enrich AppDataInfo with storage labels
@@ -1076,7 +1081,7 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
// Build unified per-app backup rows for the app-data backup UI.
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, s.langFor(r))
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
}
@@ -1092,7 +1097,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
data["InterruptedRestores"] = s.backupMgr.InterruptedRestores()
}
s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps
s.backupsOffboxData(data, s.langFor(r)) // restore-to-verify lists the offbox-toggled apps
// Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler
// redirects here with the app + human size so the confirm section can show the size BEFORE starting.
if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" {
@@ -1325,7 +1330,7 @@ func appDumpVerdict(dump *backup.DBDumpStatus, stackName string) string {
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string) []AppBackupRow {
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
@@ -1396,11 +1401,11 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
if hasDB {
base = append(base, "DB")
}
base = append(base, "Konfig")
base = append(base, s.msgLang(lang, "backup.contents.db"))
withData := func(add bool) string {
p := append([]string{}, base...)
if add {
p = append(p, "Adatok")
p = append(p, s.msgLang(lang, "backup.contents.data"))
}
return strings.Join(p, " + ")
}
@@ -1464,10 +1469,10 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// Status dot — app-data backup status
row.Status = "green"
row.StatusText = "Alkalmazás-adat mentés rendben"
row.StatusText = s.msgLang(lang, "backup.status.ok")
if hasDB && tier1DBStatus == "error" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
row.StatusText = s.msgLang(lang, "backup.status.db_failed")
}
// R-379/R-380: a HELD app must never read as healthy. Last, so it wins over both branches
// above — a warning beside a green tick is read as a success, and this is the one state where
@@ -1488,12 +1493,12 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
} else if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.no_drive")
row.Tier2LastError = cd.LastError
} else if cd.Enabled {
row.Tier2Configured = true
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
row.Tier2Schedule = "Naponta"
row.Tier2Dest = s.tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath, lang)
row.Tier2Schedule = s.msgLang(lang, "backup.tier2.schedule_daily")
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastSuccess = cd.LastSuccess
@@ -1528,11 +1533,11 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
}
switch cd.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_ok")
case "error":
row.Tier2StatusBadge = "Hiba"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_error")
case "running":
row.Tier2StatusBadge = "Fut..."
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_running")
default:
row.Tier2StatusBadge = "—"
}
@@ -1557,7 +1562,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
Slug: u.StackName,
StorageLabel: u.DriveLabel,
Status: "yellow",
StatusText: "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
StatusText: s.msgLang(lang, "backup.removed_app"),
Removed: true,
RemovedUnitTime: u.Time,
Tier1LastRun: u.Time,
@@ -1569,9 +1574,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
func (s *Server) tier2DestLabel(destPath, systemDataPath, lang string) string {
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
return "belső SSD (csak DB/konfiguráció)"
return s.msgLang(lang, "backup.tier2.dest_ssd")
}
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}
@@ -1620,7 +1625,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
@@ -1669,7 +1674,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure,
@@ -1905,7 +1910,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
@@ -1968,7 +1973,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
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)
}()
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.file_started"), http.StatusFound)
}
// backupTier2UnitRestoreHandler (R-102/R-103) restores an app IN FULL from the recovery unit mirrored
@@ -1997,7 +2002,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// R-351b (Scenario H): a second press — by button or by a direct POST — must not start a second
@@ -2046,7 +2051,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
}
s.backupMgr.EndRestoreOp(true, msg)
}()
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Teljes visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
}
// settingsBaseData is the shared identity block used by every settings-family subpage
@@ -2268,7 +2273,7 @@ func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r
// Not an error: a box that never stored one has nothing to reveal, and saying so is not a
// leak. The page does not offer the button in that case (HasRetrievalPassword gates it).
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ezen a gépen nincs tárolt visszaállítási jelszó.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.no_retrieval_password"))
return
}
// The reveal is an event, and it was not one before: the same act on the hub's break-glass
@@ -2309,18 +2314,18 @@ func (s *Server) readInitialCreds(stackName string) (*stacks.ExtractedCreds, err
// and it is what stops this becoming "read me any value out of any app's config".
func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Request, stackName string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable"))
return
}
stack, ok := s.stackMgr.GetStack(stackName)
if !ok {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
_ = r.ParseForm()
envVar := strings.TrimSpace(r.FormValue("env_var"))
if envVar == "" {
escrowJSON(w, http.StatusBadRequest, nil, "Hiányzó mező.")
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "escrow.missing_field"))
return
}
// AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata.
@@ -2333,18 +2338,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
}
if !allowed {
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
escrowJSON(w, http.StatusForbidden, nil, "Ez a mező nem kérhető le.")
escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "escrow.field_not_revealable"))
return
}
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
if appCfg == nil {
escrowJSON(w, http.StatusNotFound, nil, "Az alkalmazás beállításai nem olvashatók.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.app_settings_unreadable"))
return
}
val := crypto.DecryptMap(s.encKey, appCfg.Env)[envVar]
if strings.TrimSpace(val) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ehhez a mezőhöz nincs mentett érték.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.no_stored_value"))
return
}
s.logger.Printf("[INFO] [web] auto-generated secret revealed for %s/%s from %s (value never logged)", stackName, envVar, clientIP(r))
@@ -2370,7 +2375,7 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
// "your password is empty".
func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Request, slug string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable"))
return
}
// Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
@@ -2384,7 +2389,7 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req
}
}
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
// R-513: the file manager's password is the controller's own stored value, not a container file.
@@ -2396,13 +2401,12 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req
if err != nil {
// Never swallowed, and never surfaced raw — the error can name a container/path.
s.logger.Printf("[WARN] [web] initial-creds reveal for %s: %v", found.Name, err)
escrowJSON(w, http.StatusBadGateway, nil, "A kezdeti jelszó beolvasása nem sikerült.")
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "escrow.initial_pw_read_failed"))
return
}
if creds == nil || !creds.Available || strings.TrimSpace(creds.Password) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil,
"A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.initial_pw_unavailable"))
return
}
s.logger.Printf("[INFO] [web] initial-credential password revealed for %s from %s (value never logged)", found.Name, clientIP(r))
@@ -2419,18 +2423,18 @@ const fileBrowserStack = "filebrowser"
func (s *Server) fileBrowserPasswordReveal(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.settings == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "A beállítások nem elérhetők.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.settings_unavailable"))
return
}
state, enc, _ := s.settings.GetFileBrowserAdmin()
if state != settings.FileBrowserAdminGenerated || enc == "" {
escrowJSON(w, http.StatusNotFound, nil, "A Fájlkezelő jelszavát nem a Felhom állította be ezen a gépen, ezért nem tudjuk megmutatni.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.filebrowser_not_ours"))
return
}
pw, err := crypto.Decrypt(s.encKey, enc)
if err != nil || strings.TrimSpace(pw) == "" {
s.logger.Printf("[ERROR] [web] filebrowser password reveal: decrypt failed: %v", err)
escrowJSON(w, http.StatusInternalServerError, nil, "A jelszó most nem olvasható ki.")
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "escrow.password_unreadable"))
return
}
s.logger.Printf("[INFO] [web] filebrowser admin password revealed from %s (value never logged)", clientIP(r))
@@ -2484,21 +2488,21 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
}
data["PasswordError"] = "Hibás jelenlegi jelszó"
data["PasswordError"] = s.msg(r, "settings.pw_wrong_current")
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
data["PasswordError"] = s.msg(r, "settings.pw_too_short")
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
data["PasswordError"] = s.msg(r, "settings.pw_mismatch")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2507,7 +2511,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
data["PasswordError"] = s.msg(r, "settings.pw_save_error")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2515,7 +2519,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
// Save to settings.json
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
data["PasswordError"] = s.msg(r, "settings.pw_save_error")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2526,8 +2530,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
s.invalidateAllSessions()
// Redirect to login with flash message
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
http.Redirect(w, r, "/login?"+flashQuery("flash", "flash.login.password_changed"), http.StatusFound)
}
// sameEventSet reports whether two event lists hold the same keys, ignoring order and duplicates.
@@ -2676,7 +2679,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
data["NotificationError"] = "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket."
data["NotificationError"] = s.msg(r, "settings.notify_email_required")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2690,7 +2693,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
data := s.notificationsPageData()
data["NotificationError"] = "Hiba a beállítások mentésekor"
data["NotificationError"] = s.msg(r, "settings.notify_save_error")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2703,12 +2706,12 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
if s.notifier != nil && s.notifier.IsEnabled() {
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved_sync_failed", err)
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
}
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
}
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -2723,7 +2726,7 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
data := s.notificationsPageData()
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
data["AppEmailError"] = s.msg(r, "settings.app_email_save_error")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2735,7 +2738,7 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
if err := s.mailShim.Apply(enabled); err != nil {
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
data = s.notificationsPageData()
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
data["AppEmailError"] = s.msg(r, "settings.app_email_shim_failed")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2743,9 +2746,9 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
data = s.notificationsPageData()
if enabled {
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
data["AppEmailSuccess"] = s.msg(r, "settings.app_email_on")
} else {
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
data["AppEmailSuccess"] = s.msg(r, "settings.app_email_off")
}
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -2754,7 +2757,7 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
data := s.notificationsPageData()
if s.notifier == nil {
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
data["NotificationError"] = s.msg(r, "settings.notify_disabled")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2762,12 +2765,12 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
err := s.notifier.SendTest()
if err != nil {
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
data["NotificationError"] = s.msg(r, "settings.test_email_failed", err)
s.executeTemplate(w, r, "settings_notifications", data)
return
}
data["NotificationSuccess"] = "Teszt email elküldve."
data["NotificationSuccess"] = s.msg(r, "settings.test_email_sent")
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -3049,21 +3052,21 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
// 1. Exists and is directory
fi, err := os.Stat(path)
if err != nil || !fi.IsDir() {
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
data["StorageError"] = s.msg(r, "storage.err_path_missing")
s.executeTemplate(w, r, "storage", data)
return
}
// 2. Is mount point
if !system.IsMountPoint(path) {
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
data["StorageError"] = s.msg(r, "storage.err_not_separate")
s.executeTemplate(w, r, "storage", data)
return
}
// 3. Writable
if !system.IsWritable(path) {
data["StorageError"] = "Az útvonal nem írható."
data["StorageError"] = s.msg(r, "storage.err_not_writable")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3071,7 +3074,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
// 4. No overlap with existing paths
for _, existing := range s.settings.GetStoragePaths() {
if system.PathsOverlap(path, existing.Path) {
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
data["StorageError"] = s.msg(r, "storage.err_overlaps", existing.Path)
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3092,14 +3095,14 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
data["StorageError"] = "Hiba a mentés során."
data["StorageError"] = s.msg(r, "storage.err_save")
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_added", path)), http.StatusFound)
}
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
@@ -3115,7 +3118,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: apps using this path
apps := s.appsUsingPath(path)
if len(apps) > 0 {
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
data["StorageError"] = s.msg(r, "storage.err_in_use", strings.Join(apps, ", "))
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3123,7 +3126,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: cannot remove default
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == path && sp.IsDefault {
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
data["StorageError"] = s.msg(r, "storage.err_default")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3131,13 +3134,13 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: last path
if len(s.settings.GetStoragePaths()) <= 1 {
data["StorageError"] = "Az utolsó adattároló nem törölhető."
data["StorageError"] = s.msg(r, "storage.err_last")
s.executeTemplate(w, r, "storage", data)
return
}
if err := s.settings.RemoveStoragePath(path); err != nil {
data["StorageError"] = "Hiba a törlés során."
data["StorageError"] = s.msg(r, "storage.err_delete")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3145,7 +3148,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
// Sync FileBrowser mounts after storage path removal
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_removed", path)), http.StatusFound)
}
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
@@ -3162,7 +3165,7 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
return
}
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_default_set", path)), http.StatusFound)
}
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
@@ -3180,7 +3183,7 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt
return
}
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_state_changed", path)), http.StatusFound)
}
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
@@ -3194,7 +3197,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
if label == "" || len(label) > 50 {
data := s.storagePageData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
data["StorageError"] = s.msg(r, "storage.err_label")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3202,13 +3205,13 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
if err := s.settings.SetStorageLabel(path, label); err != nil {
s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
data := s.storagePageData()
data["StorageError"] = "Hiba a megnevezés mentésekor."
data["StorageError"] = s.msg(r, "storage.err_label_save")
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_label_changed", label)), http.StatusFound)
}
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
+165
View File
@@ -0,0 +1,165 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Localisation slice 2 (R-557), scenario S1 — a flash follows the language of the request that READS
// it, and a link minted by an older controller still reads correctly.
//
// Why this is not obvious: a flash does not travel in the response that produced it. The handler
// redirects, the browser asks for the destination, and a SECOND request renders the line. Before
// v0.252.0 the sentence itself rode in the query string, so it was written in the language of the
// handler that redirected — which is the household's language only by luck. The value is now a bundle
// key; the reader renders it.
//
// The compatibility half is the load-bearing one. A customer's open tab, a bookmark, the browser's
// back-forward cache and a mail client can all replay a URL minted by 0.251.0, whose `flash` is
// Hungarian prose. Such a value is shown verbatim, never as a raw key and never dropped.
func flashServer(t *testing.T) *Server {
t.Helper()
b, err := i18n.Load()
if err != nil {
t.Fatalf("bundle: %v", err)
}
return &Server{i18n: b}
}
func flashRequest(t *testing.T, rawQuery string) *http.Request {
t.Helper()
u, err := url.Parse("/launcher?" + rawQuery)
if err != nil {
t.Fatalf("query %q: %v", rawQuery, err)
}
return httptest.NewRequest(http.MethodGet, u.String(), nil)
}
func TestFlashKeyRoundTrip(t *testing.T) {
s := flashServer(t)
// The writer's side: launcherShareRedirect builds this.
q := flashQuery("flash", "flash.share.enabled")
if got := flashRequest(t, q).URL.Query().Get("flash"); got != "flash.share.enabled" {
t.Fatalf("the key did not survive the URL: %q", got)
}
cases := []struct {
name, query, lang, want string
}{
{
name: "hungarian reads the Hungarian sentence, byte for byte",
query: q, lang: "hu",
want: "A megosztás bekapcsolva.",
},
{
name: "english reads English",
query: q, lang: "en",
want: "Sharing is on.",
},
{
// A URL minted by v0.251.0 or earlier. `+` is a space in a query string.
name: "a legacy link carries prose and is shown verbatim",
query: "flash=Sikeres+ment%C3%A9s", lang: "en",
want: "Sikeres mentés",
},
{
name: "an unknown key-shaped value is not invented into a sentence",
query: "flash=flash.share.no_such_key", lang: "en",
want: "flash.share.no_such_key",
},
{
// The escaping case. flashText returns the value; html/template escapes it at render, as
// it always did. What must NOT happen is the value being treated as a key or dropped.
name: "a hand-typed script tag survives as text, to be escaped at render",
query: "flash=%3Cscript%3Ealert(1)%3C%2Fscript%3E", lang: "en",
want: "<script>alert(1)</script>",
},
{
name: "no flash at all is empty, not a key",
query: "", lang: "en",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := flashRequest(t, tc.query+"&lang="+tc.lang)
if got := s.flashFrom(r, "flash"); got != tc.want {
t.Errorf("flash\n got %q\n want %q", got, tc.want)
}
})
}
}
// TestFlashKeyCarriesParameters — a flash whose sentence names something (an app, a drive) carries
// that name as a separate `fa` parameter, so English may put it somewhere else in the sentence.
func TestFlashKeyCarriesParameters(t *testing.T) {
s := flashServer(t)
// The parameter-carrying flash in the bundle today. Asserted through the real bundle rather than a
// fixture, and self-arming: if no flash message takes a parameter any more the test says so out
// loud instead of passing vacuously.
const key = "flash.offbox.config_invalid"
if !s.i18n.Has(i18n.Default, key) {
t.Fatalf("%s left the bundle — this test no longer covers a parameter-carrying flash", key)
}
if !strings.Contains(s.i18n.Msg(i18n.Default, key), "%s") {
t.Fatalf("%s no longer takes a parameter — point this test at one that does", key)
}
r := flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=hu")
got := s.flashFrom(r, "flash_error")
if want := "Érvénytelen beállítás: a port nem szam"; got != want {
t.Errorf("parameter did not reach the message\n got %q\n want %q", got, want)
}
// And the same key, same parameter, in English: the parameter survives a different word order.
r = flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=en")
if got := s.flashFrom(r, "flash_error"); !strings.Contains(got, "a port nem szam") {
t.Errorf("the parameter was lost in English: %q", got)
}
}
// TestFlashOnAServerWithNoBundleField — a Server built without going through loadTemplates still
// renders SENTENCES, never raw keys. s.bundle() falls back to the embedded i18n.Shared() for exactly
// this: a page that shows „flash.share.enabled" to a household is the failure mode §4 of the
// localisation design forbids, and it was reachable from any construction path but the real one.
//
// RED-PROOF (REPORT): make s.bundle() return s.i18n unchanged → this test reads back the key.
func TestFlashOnAServerWithNoBundleField(t *testing.T) {
s := &Server{} // i18n field never set
if got := s.flashFrom(flashRequest(t, "flash=flash.share.enabled"), "flash"); got != "A megosztás bekapcsolva." {
t.Errorf("a Server with no bundle field showed %q instead of the sentence", got)
}
// And a legacy sentence still passes through untouched.
if got := s.flashFrom(flashRequest(t, "flash=Sikeres+ment%C3%A9s"), "flash"); got != "Sikeres mentés" {
t.Errorf("got %q, want the legacy text verbatim", got)
}
}
// flashSentence resolves the flash a redirect Location carries, in Hungarian.
//
// It exists because v0.252.0 moved the SENTENCE out of the URL and put a bundle KEY there instead.
// A test that searched the Location header for „elindult" or „letét" was never asserting the URL —
// it was asserting what the customer is told — and that is still exactly checkable, one lookup
// later. Tests assert through this rather than against the key, so a key renamed with its message
// intact stays green and a message REWORDED still fails, which is the right way round.
func flashSentence(t *testing.T, location string) string {
t.Helper()
u, err := url.Parse(location)
if err != nil {
t.Fatalf("Location %q: %v", location, err)
}
s := flashServer(t)
r := httptest.NewRequest(http.MethodGet, location, nil)
for _, p := range []string{"flash", "flash_error"} {
if v := u.Query().Get(p); v != "" {
return s.flashText(r, v)
}
}
return ""
}
+145 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
@@ -18,6 +19,132 @@ import (
// 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)
}
// ── 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.
// 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
@@ -30,10 +157,27 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
data["LangSwitch"] = true
data["LangSwitchBack"] = r.URL.Path
}
// 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 {
data["Title"] = s.i18n.Msg(lang, key)
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)
}
}
}
@@ -358,6 +358,13 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
// title, titleKey := "Title", "key"
regexp.MustCompile(`title, titleKey :?= "([^"]+)", "([^"]+)"`),
}
// R-566, v0.252.0 — the three titles built around an app name, plus the tier-2 one. Their
// Hungarian is a CONCATENATION, so there is no single literal to compare with; the pair is pinned
// instead by TestParameterisedPageTitles (the rendered title) and by scripts/i18n_go_parity.py
// (the key's text against the base-commit fragment). Collected here so the "every page.title.* key
// has a handler" half below still accounts for them.
withArgs := regexp.MustCompile(`data\["TitleKey"\], data\["TitleArgs"\] = (?:pageTitleKey|"([^"]+)")`)
argKeys := []string{"page.title.logs", "page.title.deploy", "page.title.app_settings", "page.title.tier2_config"}
files, _ := filepath.Glob("*.go")
seen := map[string]bool{}
for _, f := range files {
@@ -368,6 +375,11 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, m := range withArgs.FindAllStringSubmatch(string(src), -1) {
if m[1] != "" {
seen[m[1]] = true
}
}
for _, re := range pairs {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
seen[m[2]] = true
@@ -377,6 +389,11 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
}
}
}
// The two keys a `pageTitleKey` variable carries cannot be read off the assignment line; they are
// named here and proven by TestParameterisedPageTitles.
for _, k := range argKeys {
seen[k] = true
}
for _, k := range b.Keys(i18n.Default) {
if strings.HasPrefix(k, "page.title.") && !seen[k] {
t.Errorf("%s has no handler setting it next to its Hungarian title", k)
@@ -499,3 +516,39 @@ func TestI18nDirectRenderPagesHaveNoAdminChrome(t *testing.T) {
}
}
}
// TestParameterisedPageTitles — R-566, v0.252.0. Three page titles are built in Go AROUND an app
// name („<app> — Naplók", „<app> — Telepítés" / „— Beállítások", „2. mentés beállítása — <app>").
// Slice 1 gave every STATIC title a key and left these three Hungarian in the browser tab while the
// page body was English, because one static message cannot hold a name.
//
// What this pins: rendering the key with the app name produces EXACTLY the string the handler's
// concatenation produced before, and English differs. The first half is the parity rule for a title;
// the second is the reason the row exists.
func TestParameterisedPageTitles(t *testing.T) {
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
const app = "PrivateBin"
cases := []struct {
key, wantHU string
}{
{"page.title.logs", app + " — Naplók"},
{"page.title.deploy", app + " — Telepítés"},
{"page.title.app_settings", app + " — Beállítások"},
{"page.title.tier2_config", "2. mentés beállítása — " + app},
}
for _, c := range cases {
if got := b.Msgf(i18n.Default, c.key, app); got != c.wantHU {
t.Errorf("%s in Hungarian:\n got %q\n want %q (the concatenation this replaced)", c.key, got, c.wantHU)
}
en := b.Msgf("en", c.key, app)
if !strings.Contains(en, app) {
t.Errorf("%s in English lost the app name: %q", c.key, en)
}
if en == c.wantHU {
t.Errorf("%s was never translated — English still reads %q", c.key, en)
}
}
}
+11 -3
View File
@@ -16,6 +16,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -30,7 +31,14 @@ func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Mana
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}, sett, m
// The bundle is loaded here (v0.252.0, R-557) because the page view-model text now comes from it.
// Every Hungarian assertion in the tests below is therefore a measurement of hu.json against the
// sentence the page used to carry -- which is the parity property, checked where it is read.
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg, i18n: b}, sett, m
}
// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable.
@@ -51,7 +59,7 @@ func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
// run while pending → refused with the escrow-wait flash, no run launched
w := httptest.NewRecorder()
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(loc, "let%C3%A9t") {
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(flashSentence(t, loc), "letétbe") {
t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc)
}
if m.OffboxRunnable() {
@@ -114,7 +122,7 @@ func TestOffboxRun_RefusedWhenOrphaned(t *testing.T) {
if w.Code != 302 {
t.Fatalf("orphaned run must redirect, got %d", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "el%C3%A1rvult") {
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elárvult") {
t.Fatalf("orphaned run must redirect to the orphan-card flash, got %q", loc)
}
}
+63 -53
View File
@@ -23,11 +23,17 @@ import (
// offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or
// error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the
// restore-to-verify flow redirects to /backups/restore via offboxRedirectTo).
func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) {
offboxRedirectTo(w, r, "/backups/remote", msg, isErr)
func offboxRedirect(w http.ResponseWriter, r *http.Request, key string, isErr bool, args ...string) {
offboxRedirectTo(w, r, "/backups/remote", key, isErr, args...)
}
func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) {
// offboxRedirectTo sends the customer to `page` with a flash.
//
// `key` names a bundle message, not a sentence (v0.252.0, R-557) — the page that RENDERS the flash is
// reached by a second request, and only that request knows the household's language. `args` fill the
// message's printf verbs and ride as repeated `fa` parameters. A value the bundle does not know is
// shown verbatim, so a link minted by an older controller still reads correctly.
func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, key string, isErr bool, args ...string) {
q := "flash"
if isErr {
q = "flash_error"
@@ -39,13 +45,13 @@ func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string,
if strings.Contains(page, "?") {
sep = "&"
}
http.Redirect(w, r, page+sep+q+"="+url.QueryEscape(msg), http.StatusFound)
http.Redirect(w, r, page+sep+flashQuery(q, key, args...), http.StatusFound)
}
// offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts.
func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
offboxRedirect(w, r, "flash.offbox.mgr_unavailable", true)
return
}
_ = r.ParseForm()
@@ -60,30 +66,30 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
knownHosts := r.FormValue("known_hosts")
if host == "" || user == "" || repoPath == "" {
offboxRedirect(w, r, "A cél címe, a felhasználó és a tárhely útvonala kötelező.", true)
offboxRedirect(w, r, "flash.offbox.fields_required", true)
return
}
if !strings.HasPrefix(repoPath, "/") {
offboxRedirect(w, r, "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", true)
offboxRedirect(w, r, "flash.offbox.path_absolute", true)
return
}
// Validate BEFORE persisting — host/user/repo flow into the ssh command restic runs; reject anything
// that could inject an ssh option (leading '-') or a metacharacter (the security boundary).
if err := backup.ValidateOffboxTarget(&settings.OffboxTarget{Host: host, User: user, RepoPath: repoPath, Port: port}); err != nil {
offboxRedirect(w, r, "Érvénytelen beállítás: "+err.Error(), true)
offboxRedirect(w, r, "flash.offbox.config_invalid", true, err.Error())
return
}
// First-time config requires the SSH key + a pinned known-host line (no blind TOFU).
existing := s.backupMgr.OffboxConfigured()
if !existing && (strings.TrimSpace(sshKey) == "" || strings.TrimSpace(knownHosts) == "") {
offboxRedirect(w, r, "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", true)
offboxRedirect(w, r, "flash.offbox.first_setup_needs_key", true)
return
}
// Write secrets out-of-band (0600 key/pw, 0644 known_hosts); never logged.
if err := s.backupMgr.WriteOffboxSecrets(sshKey, knownHosts); err != nil {
s.logger.Printf("[ERROR] [web] offbox secrets: %v", err)
offboxRedirect(w, r, "A hitelesítő adatok mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.creds_save_failed", true)
return
}
@@ -110,26 +116,30 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
// escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push
// failure does NOT mark escrowed; it is surfaced (the run gate still protects data).
stageErr := ""
stageKey := ""
if tgt.Enabled {
if tgt.EscrowState != "escrowed" {
tgt.EscrowState = "pending"
}
if client, cerr := s.agentClient(); cerr != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
stageKey = "flash.offbox.target_saved_escrow_agent_down"
s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr)
} else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra."
stageKey = "flash.offbox.target_saved_escrow_failed"
s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret
}
}
if err := s.settings.SetOffboxTarget(tgt); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
savedKey := "flash.offbox.target_saved"
if stageKey != "" {
savedKey = stageKey
}
offboxRedirect(w, r, savedKey, stageKey != "")
}
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4).
@@ -139,14 +149,14 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
// no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony.
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.EscrowState = "escrowed"
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
}); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
@@ -156,7 +166,7 @@ func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Reque
if err := s.wipeStagedEscrow(r.Context()); err != nil {
s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err)
}
offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.", false)
offboxRedirect(w, r, "flash.offbox.escrow_confirmed", false)
}
// wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the
@@ -179,22 +189,22 @@ func (s *Server) wipeStagedEscrow(ctx context.Context) error {
// is never logged. Body: {password, force?}.
func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
offboxRedirect(w, r, "flash.offbox.mgr_unavailable", true)
return
}
_ = r.ParseForm()
pw := r.FormValue("password")
force := r.FormValue("force") == "on" || r.FormValue("force") == "true"
if strings.TrimSpace(pw) == "" {
offboxRedirect(w, r, "A repo jelszó kötelező.", true)
offboxRedirect(w, r, "flash.offbox.repo_pw_required", true)
return
}
if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil {
offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true)
offboxRedirect(w, r, "flash.offbox.repo_pw_failed", true, err.Error())
return
}
s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force)
offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false)
offboxRedirect(w, r, "flash.offbox.repo_pw_set", false)
}
// offboxToggleHandler flips an app's off-box inclusion.
@@ -203,32 +213,32 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
app := strings.TrimSpace(r.FormValue("app"))
on := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
if app == "" {
offboxRedirect(w, r, "Hiányzó alkalmazás.", true)
offboxRedirect(w, r, "flash.offbox.app_missing", true)
return
}
if err := s.settings.SetAppOffbox(app, on); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
offboxRedirect(w, r, "flash.offbox.app_setting_updated", false)
}
// offboxRunHandler triggers an off-box backup now (async — it can run for minutes).
func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
// fork-4 atomicity: refuse the run until the repo password is escrowed under R.
if !s.backupMgr.OffboxRunnable() {
offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
offboxRedirect(w, r, "flash.offbox.waiting_for_escrow", true)
return
}
// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
// orphan card's explanation/reset instead of attempting a doomed write.
if s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
offboxRedirect(w, r, "flash.offbox.repo_orphaned", true)
return
}
// R-234: the single-flight decision is taken SYNCHRONOUSLY, before the goroutine, so the customer
@@ -239,7 +249,7 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
// question is about. (The documented "use RestoreStatus for display" trap is a different question.)
if s.backupMgr.IsRunning() {
s.logger.Printf("[INFO] [web] manual off-box backup NOT started for this request: a run is already in flight")
offboxRedirect(w, r, "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.", true)
offboxRedirect(w, r, "flash.offbox.run_already_going", true)
return
}
go func() {
@@ -256,7 +266,7 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
}
}()
offboxRedirect(w, r, "A távoli mentés elindult — az állapot itt frissül.", false)
offboxRedirect(w, r, "flash.offbox.run_started", false)
}
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
@@ -264,15 +274,15 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
if !s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
offboxRedirect(w, r, "flash.offbox.not_orphaned", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
offboxRedirect(w, r, "flash.offbox.needs_confirmation", true)
return
}
go func() {
@@ -282,7 +292,7 @@ func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
}
}()
offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
offboxRedirect(w, r, "flash.offbox.restart_started", false)
}
// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
@@ -314,13 +324,13 @@ func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
mode := strings.TrimSpace(r.FormValue("mode"))
@@ -377,7 +387,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
where := s.backupMgr.OffsiteRestoreScratchPath(app)
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.restore_started", false)
}
// restoreScratchOutcomeMsg builds the OUTCOME flash for a completed scratch restore. Pure, so the
@@ -420,18 +430,18 @@ func restoreScratchOutcomeMsg(app, where string, full bool) string {
// because that is the only part the customer can check against what they see in the app.
func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
// This one overwrites live files and replays a database — it must never happen on a stray click.
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -465,7 +475,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
}
// offsiteScratchIncompleteMsg (R-358) is the server-side refusal shown when a place or reconstitute is
@@ -535,17 +545,17 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
// It now refuses while ANY backup or restore op is running.
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
stack := strings.TrimSpace(r.FormValue("stack"))
if stack == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.scratch_missing", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.delete_needs_confirmation", true)
return
}
// R-360: `restoreOpBlocked()` and not `IsRunning()`. No app-name comparison is added deliberately:
@@ -559,24 +569,24 @@ func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Re
}
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.delete_failed", true, err.Error())
return
}
s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.scratch_deleted", false)
}
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -603,7 +613,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
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é.")
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A helyreállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.recover_started", false)
}
// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
@@ -617,7 +627,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -636,14 +646,14 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
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é.")
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_restore_started", false)
}
// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
// missing definitions and restores the household credential (POST /backup/shares/place).
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -664,5 +674,5 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
res.FilesRestored, len(res.DefinitionsAdded))
s.backupMgr.EndRestoreOp(true, res.FlashMessage())
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_recover_started", false)
}
@@ -55,14 +55,14 @@ func TestOffboxRunHandler_InFlightRequestIsNotReportedAsStarted(t *testing.T) {
t.Fatalf("the handler redirects; got %d", w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "elind") {
if strings.Contains(flashSentence(t, loc), "elind") {
t.Errorf("a dropped request must NOT be reported as started — that is the defect. Location: %q", loc)
}
if !strings.Contains(loc, "flash_error") {
t.Errorf("it must reach the customer as a problem, not a success flash. Location: %q", loc)
}
// It must also say the visible result belongs to the EARLIER run — that is what was misread.
if !strings.Contains(loc, "kor%C3%A1bbi") {
if !strings.Contains(flashSentence(t, loc), "korábbi") {
t.Errorf("the message must say the shown result is the earlier run's. Location: %q", loc)
}
if !strings.Contains(logbuf.String(), "NOT started") {
@@ -19,35 +19,39 @@ import (
const staleZeroToggleWarning = "Sikeres — nincs mentésre jelölt alkalmazás volt a futáskor."
func TestOffboxWarningDisplay_Pick(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
// warning + ≥1 enabled → the replacement line
if got := offboxWarningDisplay(staleZeroToggleWarning, "", 1); got != offboxSelectionChangedLine {
if got := s.offboxWarningDisplay(staleZeroToggleWarning, "", 1, "hu"); got != selectionChanged {
t.Errorf("stale warning + 1 toggled: got %q, want the selection-changed line", got)
}
// warning + 0 enabled → the original line, verbatim (the v0.123.0 honesty stays)
if got := offboxWarningDisplay(staleZeroToggleWarning, "", 0); got != staleZeroToggleWarning {
if got := s.offboxWarningDisplay(staleZeroToggleWarning, "", 0, "hu"); got != staleZeroToggleWarning {
t.Errorf("stale warning + 0 toggled: got %q, want the original warning unchanged", got)
}
// any OTHER warning passes through regardless of toggles (quota, partial failure)
quota := "A tároló a keret 84%-át használja (42/50 GB)."
if got := offboxWarningDisplay(quota, "", 3); got != quota {
if got := s.offboxWarningDisplay(quota, "", 3, "hu"); got != quota {
t.Errorf("non-stale warning must pass through verbatim, got %q", got)
}
// no warning → no line
if got := offboxWarningDisplay("", "", 2); got != "" {
if got := s.offboxWarningDisplay("", "", 2, "hu"); got != "" {
t.Errorf("empty warning must stay empty, got %q", got)
}
}
func TestOffboxWarningDisplay_RemotePageRender(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
// ≥1 toggled: the page shows the replacement, NOT the stale line.
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, "", 1)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(staleZeroToggleWarning, "", 1, "hu")
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, offboxSelectionChangedLine) {
if !strings.Contains(html, selectionChanged) {
t.Error("selection-changed note missing with 1 app toggled")
}
if strings.Contains(html, staleZeroToggleWarning) {
@@ -62,12 +66,12 @@ func TestOffboxWarningDisplay_RemotePageRender(t *testing.T) {
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, "", 0)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(staleZeroToggleWarning, "", 0, "hu")
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, staleZeroToggleWarning) {
t.Error("0 toggled: the original run warning must render unchanged")
}
if strings.Contains(html, offboxSelectionChangedLine) {
if strings.Contains(html, selectionChanged) {
t.Error("0 toggled: the selection-changed note must NOT render")
}
if !strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.") {
@@ -392,7 +392,7 @@ func TestR103_UnitRestoreHandlerGuards(t *testing.T) {
}
}
rec := postTier2UnitRestore(t, s, "docmost")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
if loc := rec.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "Mentés nincs beállítva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}
@@ -104,7 +104,7 @@ func TestR487_BuildAppBackupRowsAppendsRemovedApps(t *testing.T) {
if err := os.WriteFile(backup.UnitManifestFile(unit), []byte(`{"schema_version":2,"app_name":"gone-app","display_name":"Gone","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{})
rows := s.buildAppBackupRows(&backup.FullBackupStatus{}, "hu")
if len(rows) != 1 || rows[0].StackName != "gone-app" || !rows[0].Removed || rows[0].DisplayName != "Gone" || rows[0].RemovedUnitTime == "" {
t.Fatalf("want one removed row for gone-app, got %+v", rows)
}
@@ -44,7 +44,7 @@ func TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold(t *testing.T) {
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "nextcloud", DisplayName: "Nextcloud", HasHDDData: true, HasVolumeData: true},
{StackName: "privatebin", DisplayName: "PrivateBin", HasVolumeData: true},
}})
}}, "hu")
nc := findRow(rows, "nextcloud")
if nc == nil {
@@ -43,9 +43,9 @@ func TestR553_DiskWarningPlacementSurvivesWordingChange(t *testing.T) {
cfg.Hub.Enabled = false
am.Refresh(hr, cfg, nil, false, "")
var found *Alert
for i, a := range am.GetAlerts() {
for i, a := range am.GetAlerts("hu") {
if a.Message == c.text {
found = &am.GetAlerts()[i]
found = &am.GetAlerts("hu")[i]
}
}
if found == nil {
@@ -19,6 +19,10 @@ import (
// RED-PROOF (REPORT): make offboxWarningDisplay decide by the marker again (drop the kind arm) and
// the TRANSLATED row fails: the stale sentence is shown verbatim.
func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
// The replacement note is read from the bundle (v0.252.0, R-557): the same sentence the constant
// used to hold, now measured against hu.json rather than restated beside it.
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
const hu = "Sikeres — nincs mentésre jelölt alkalmazás"
const en = "Success — no app is selected for backup"
cases := []struct {
@@ -26,18 +30,18 @@ func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
toggled int
want string
}{
{"as shipped, apps now selected", hu, backup.OffboxWarnNoAppsSelected, 1, offboxSelectionChangedLine},
{"TRANSLATED, apps now selected", en, backup.OffboxWarnNoAppsSelected, 2, offboxSelectionChangedLine},
{"as shipped, apps now selected", hu, backup.OffboxWarnNoAppsSelected, 1, selectionChanged},
{"TRANSLATED, apps now selected", en, backup.OffboxWarnNoAppsSelected, 2, selectionChanged},
{"still nothing selected → the honest note stays", hu, backup.OffboxWarnNoAppsSelected, 0, hu},
{"a quota note passes through", "A tároló a keret 84%-át használja (42/50 GB).", "", 3,
"A tároló a keret 84%-át használja (42/50 GB)."},
// LEGACY: a box upgraded to v0.251.0 still carries the OLD text with no kind until its next
// off-site run rewrites it. Until then the text test is what it has.
{"legacy persisted text, no kind", hu, "", 1, offboxSelectionChangedLine},
{"legacy persisted text, no kind", hu, "", 1, selectionChanged},
{"legacy: any other text without a kind is shown verbatim", "Valami más történt.", "", 1, "Valami más történt."},
}
for _, c := range cases {
if got := offboxWarningDisplay(c.warning, c.kind, c.toggled); got != c.want {
if got := s.offboxWarningDisplay(c.warning, c.kind, c.toggled, "hu"); got != c.want {
t.Errorf("%s:\n got %q\nwant %q", c.name, got, c.want)
}
}
@@ -46,15 +50,17 @@ func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
// The consequence on the page itself: with a TRANSLATED warning that carries the kind, the rendered
// Távoli mentés page shows the selection-changed note and not the stale sentence.
func TestR553_StaleNoteOnTheRenderedPage(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
const en = "Success — no app is selected for backup"
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: en, LastWarningKind: backup.OffboxWarnNoAppsSelected,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(en, backup.OffboxWarnNoAppsSelected, 1)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(en, backup.OffboxWarnNoAppsSelected, 1, "hu")
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, offboxSelectionChangedLine) {
if !strings.Contains(html, selectionChanged) {
t.Error("the selection-changed note is missing for a translated warning that carries its kind")
}
if strings.Contains(html, en) {
+1 -1
View File
@@ -495,7 +495,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
case path == "/claim" && r.Method == http.MethodGet:
s.handleClaimPage(w, r, "", r.URL.Query().Get("flash"))
s.handleClaimPage(w, r, "", s.flashFrom(r, "flash"))
case path == "/claim" && r.Method == http.MethodPost:
s.handleClaimSubmit(w, r)
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
+25 -20
View File
@@ -171,9 +171,14 @@ func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request,
// ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ─────────
// launcherShareRedirect returns to /launcher with a Hungarian flash (reuses urlQueryEscape).
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), http.StatusSeeOther)
// launcherShareRedirect returns to /launcher with a flash.
//
// `key` is a bundle KEY, not a sentence (v0.252.0, R-557): the redirect is read by a DIFFERENT
// request, and only that request knows the household's language. The launcher resolves it with
// s.flashText, which shows a sentence minted by an older controller verbatim — so a link already in
// a customer's tab still reads correctly.
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, key string) {
http.Redirect(w, r, "/launcher?"+flashQuery("flash", key), http.StatusSeeOther)
}
// launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so
@@ -199,44 +204,44 @@ func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request)
// launcherShareEnableHandler mints the first token (POST /launcher/share/enable).
func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() != "" {
s.launcherShareRedirect(w, r, "A megosztás már be van kapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.already_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link enabled")
s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.enabled")
}
// launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and
// every outstanding guest cookie is invalidated (both are bound to the token).
func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link rotated")
s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.")
s.launcherShareRedirect(w, r, "flash.share.relinked")
}
// launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) —
@@ -244,14 +249,14 @@ func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Reque
func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) {
if err := s.settings.SetLauncherShareToken(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás kikapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.disable_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err)
}
s.logger.Printf("[INFO] [web] launcher share link disabled")
s.launcherShareRedirect(w, r, "A megosztás kikapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.disabled")
}
// launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST
@@ -260,35 +265,35 @@ func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Requ
func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
if r.FormValue("action") == "clear" {
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó törlése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_clear_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password cleared")
s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.")
s.launcherShareRedirect(w, r, "flash.share.pw_cleared")
return
}
pw := r.FormValue("password")
if len(pw) < shareMinPassword {
s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
s.launcherShareRedirect(w, r, "flash.share.pw_too_short")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password set")
s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.")
s.launcherShareRedirect(w, r, "flash.share.pw_set")
}
+17 -17
View File
@@ -238,7 +238,7 @@ func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot {
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
data := s.sharingPageData()
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
if f := s.flashFrom(r, "flash"); f != "" {
data["Flash"] = f
}
s.executeTemplate(w, r, "sharing", data)
@@ -246,7 +246,7 @@ func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
// sharingRedirect returns to the page with a Hungarian flash.
func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/sharing?flash="+urlQueryEscape(flash), http.StatusSeeOther)
http.Redirect(w, r, "/sharing?"+flashQuery("flash", flash), http.StatusSeeOther)
}
func urlQueryEscape(s string) string {
@@ -265,19 +265,19 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.settings.SetSMBServerName(name); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
sharingRedirect(w, r, "flash.sharing.save_failed")
return
}
}
if err := s.settings.SetSMBEnabled(enable); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
sharingRedirect(w, r, "flash.sharing.save_failed")
return
}
if !enable {
if err := s.stackMgr.DisableSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] disable failed: %v", err)
}
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
sharingRedirect(w, r, "flash.sharing.disabled")
return
}
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
@@ -292,10 +292,10 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
sharingRedirect(w, r, "flash.sharing.prepare_running")
return
}
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
sharingRedirect(w, r, "flash.sharing.saved_preparing")
}
// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
@@ -333,26 +333,26 @@ func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request)
pw := r.FormValue("smb_password")
pw2 := r.FormValue("smb_password_confirm")
if len(pw) < 8 {
sharingRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
sharingRedirect(w, r, "flash.sharing.pw_too_short")
return
}
if pw != pw2 {
sharingRedirect(w, r, "A két jelszó nem egyezik.")
sharingRedirect(w, r, "flash.sharing.pw_mismatch")
return
}
if err := s.stackMgr.SetSMBPassword(pw); err != nil {
s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err)
sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
sharingRedirect(w, r, "flash.sharing.pw_set_failed")
return
}
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
// where the image pull happens. Same detached job, same card.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
sharingRedirect(w, r, "flash.sharing.pw_set_preparing_already")
return
}
sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
sharingRedirect(w, r, "flash.sharing.pw_set_preparing")
}
// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
@@ -385,7 +385,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
}
if err := os.MkdirAll(dir, 0o775); err != nil {
s.logger.Printf("[ERROR] [sharing] mkdir failed: %v", err)
sharingRedirect(w, r, "A mappa létrehozása nem sikerült.")
sharingRedirect(w, r, "flash.sharing.folder_failed")
return
}
// uid:gid 1000 so apps and both backup tiers see the same ownership as SMB writes.
@@ -419,7 +419,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share create failed: %v", err)
}
sharingRedirect(w, r, "A megosztás létrehozva.")
sharingRedirect(w, r, "flash.sharing.created")
}
// sharingShareDeleteHandler removes a share (POST /sharing/shares/delete). CONFIG ONLY — by
@@ -432,7 +432,7 @@ func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Reques
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, name) && sh.System {
sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
sharingRedirect(w, r, "flash.sharing.protected")
return
}
}
@@ -443,7 +443,7 @@ func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Reques
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share delete failed: %v", err)
}
sharingRedirect(w, r, "A megosztás törölve — a mappa és a fájlok megmaradtak.")
sharingRedirect(w, r, "flash.sharing.deleted")
}
// sharingShareOffsiteHandler flips a share's „Felhőmentés" toggle (POST /sharing/shares/offsite).
@@ -455,7 +455,7 @@ func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Reque
sharingRedirect(w, r, err.Error())
return
}
sharingRedirect(w, r, "Beállítás mentve.")
sharingRedirect(w, r, "flash.sharing.saved")
}
// ServeSharingAPI dispatches the /api/sharing/* XHR endpoints. Registered on the mux in main.go
@@ -41,7 +41,7 @@ func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
t.Fatal(err)
}
wantDate, wantStale := cov.UnitRestoreDate()
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}})
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}}, "hu")
row := findRow(rows, "app")
if row == nil {
t.Fatal("no row")
+26 -26
View File
@@ -420,7 +420,7 @@ func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) {
@@ -441,7 +441,7 @@ func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request)
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
// R-108: the TARGET may not be network storage. Its whole-namespace sibling
@@ -477,12 +477,12 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
MountName string `json:"mount_name"` // type-to-confirm for mode=anyway
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -492,7 +492,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
switch req.Mode {
case "migrate":
if strings.TrimSpace(req.Target) == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.target_required"), nil)
return
}
// R-108: the refuseNetworkLifecycle above guards `req.Where` — the SOURCE. The TARGET was
@@ -515,7 +515,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
case "anyway":
// Type-to-confirm: the typed name must match the mount basename exactly (mirrors wipe).
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
return
}
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
@@ -545,7 +545,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
default:
writeDiskJSON(w, http.StatusBadRequest, false, "ismeretlen mód (migrate vagy anyway)", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.unknown_mode"), nil)
}
}
@@ -624,7 +624,7 @@ type storageProvReq struct {
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
where, err := mountWhere(req.MountName)
@@ -633,7 +633,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
agent, err := s.agentClient()
@@ -649,7 +649,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
setDefault: req.SetDefault, confirmed: req.Confirmed, durableID: req.DurableID,
})
if !started {
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy meghajtó-inicializálás", nil)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.init_in_progress"), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "phase": storageInitPhaseFormatting})
@@ -675,7 +675,7 @@ func (s *Server) handleStorageInitStatus(w http.ResponseWriter, r *http.Request)
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
apps := s.appsUsingPath(where)
@@ -734,16 +734,16 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
FSType string `json:"fstype"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -751,7 +751,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
}
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
return
}
fstype := req.FSType
@@ -778,12 +778,12 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
if errors.Is(perr, agentapi.ErrFormatRefused) {
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.protected"), probe)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", perr.Error()), nil)
return
}
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
@@ -792,7 +792,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
}
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
if ferr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", ferr.Error()), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
@@ -838,12 +838,12 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
// req.Where is the RAW /mnt/<name> host mount the agent reports; in the intermediary model the drive
@@ -888,7 +888,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
want := path.Clean(strings.TrimSpace(req.Path))
@@ -903,7 +903,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
if match == nil {
// Names a reason the customer can act on, and a route — never a bare refusal.
writeDiskJSON(w, http.StatusBadRequest, false,
"Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.", nil)
s.msg(r, "disk.err.attach_unavailable"), nil)
return
}
if err := s.registerStoragePath(match.Path, req.Label, req.SetDefault); err != nil {
@@ -918,7 +918,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
where, err := mountWhere(req.MountName)
@@ -927,7 +927,7 @@ func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
agent, err := s.agentClient()
@@ -950,12 +950,12 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
Where string `json:"where"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -34,10 +34,10 @@ func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request,
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "tier2_config", data)
@@ -69,14 +69,14 @@ func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request,
}
}
if !valid {
s.redirectTier2(w, r, name, "", "A választott cél meghajtó nem érvényes.")
s.redirectTier2(w, r, name, "", "flash.tier2.target_invalid")
return
}
}
if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil {
s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err)
s.redirectTier2(w, r, name, "", "A beállítás mentése nem sikerült.")
s.redirectTier2(w, r, name, "", "flash.tier2.save_failed")
return
}
s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target)
@@ -90,7 +90,7 @@ func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request,
}()
}
s.redirectTier2(w, r, name, "A 2. mentés beállítása elmentve.", "")
s.redirectTier2(w, r, name, "flash.tier2.saved", "")
}
// appEmailToggleHandler flips the per-app email toggle (only for apps with an smtp_mapping)
@@ -111,11 +111,11 @@ func (s *Server) appEmailToggleHandler(w http.ResponseWriter, r *http.Request, n
return
}
s.logger.Printf("[INFO] [web] App-email for %s set to %v", name, enabled)
msg := "Email-küldés kikapcsolva ennél az alkalmazásnál."
msg := "flash.tier2.app_email_off"
if enabled {
msg = "Email-küldés bekapcsolva ennél az alkalmazásnál."
msg = "flash.tier2.app_email_on"
}
http.Redirect(w, r, dest+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
http.Redirect(w, r, dest+"?"+flashQuery("flash", msg), http.StatusSeeOther)
}
// redirectTier2 sends the customer back to the panel with a flash message.
@@ -44,7 +44,7 @@ func TestTier2RestoreHandler_Guards(t *testing.T) {
// Valid name but no backup manager → the not-configured flash (still no panic, no work).
rec := postTier2Restore(t, s, "nextcloud")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
if loc := rec.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "Mentés nincs beállítva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}