60f0a86bd4
gates / gates (push) Successful in 25s
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.
`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.
Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.
Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.
MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
3506 lines
158 KiB
Go
3506 lines
158 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// protectedStackSubdomains maps programmatically managed protected stacks
|
||
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
|
||
var protectedStackSubdomains = map[string]string{
|
||
"filebrowser": "files",
|
||
}
|
||
|
||
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
|
||
type StorageBarInfo struct {
|
||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||
Path string // e.g., "/mnt/hdd_1"
|
||
Purpose string // Hungarian explanation of what this drive holds (monitoring page)
|
||
TotalGB float64
|
||
UsedGB float64
|
||
Percent float64
|
||
Disconnected bool
|
||
}
|
||
|
||
// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
|
||
// 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 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(lang string) []StorageBarInfo {
|
||
var bars []StorageBarInfo
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
// Skip decommissioned drives — they are no longer in active use
|
||
if sp.Decommissioned {
|
||
continue
|
||
}
|
||
if sp.Disconnected {
|
||
bars = append(bars, StorageBarInfo{
|
||
Label: sp.Label,
|
||
Path: sp.Path,
|
||
Purpose: s.msgLang(lang, storageBarPurposeKey),
|
||
Disconnected: true,
|
||
})
|
||
continue
|
||
}
|
||
di := system.GetDiskUsage(sp.Path)
|
||
if di == nil {
|
||
continue
|
||
}
|
||
bars = append(bars, StorageBarInfo{
|
||
Label: sp.Label,
|
||
Path: sp.Path,
|
||
Purpose: s.msgLang(lang, storageBarPurposeKey),
|
||
TotalGB: di.TotalGB,
|
||
UsedGB: di.UsedGB,
|
||
Percent: di.UsedPercent,
|
||
})
|
||
}
|
||
// Deterministic order regardless of registry insertion order.
|
||
sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
|
||
return bars
|
||
}
|
||
|
||
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
|
||
type DeployStoragePath struct {
|
||
settings.StoragePath
|
||
FreeHuman string // "234.5 GB"
|
||
FreePercent float64 // 67.5
|
||
// NotAllowed marks a path that CANNOT host an app's data namespace (R-108: network storage). The
|
||
// option is rendered PRESENT-but-disabled with NotAllowedNote rather than dropped: a NAS the
|
||
// customer registered themselves, silently absent from the list they expect it in, reads as a bug
|
||
// and generates a support question. Present with a reason answers the question in place.
|
||
NotAllowed bool
|
||
NotAllowedNote string // short parenthetical for the option label; "" when allowed
|
||
}
|
||
|
||
// StorageAppDetail holds info about an app using a specific storage path.
|
||
type StorageAppDetail struct {
|
||
Name string // Display name (e.g., "Immich")
|
||
Stack string // Stack name (for link)
|
||
SizeHuman string // Data size on this path
|
||
}
|
||
|
||
// StoragePathView extends StoragePath with display data for the settings page.
|
||
type StoragePathView struct {
|
||
settings.StoragePath
|
||
DiskInfo *system.DiskUsageInfo
|
||
AppCount int
|
||
IsMounted bool
|
||
AppDetails []StorageAppDetail
|
||
FSInfo *system.FSInfo
|
||
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
|
||
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
|
||
MigratedToLabel string // label of the drive data was migrated to
|
||
HasOtherPaths bool // true if other connected non-decommissioned paths exist
|
||
IsEnrolled bool // enrolled via the wizard (stable /mnt/felhom-drives/ path) — lifecycle is disconnect/decommission, not list-removal
|
||
}
|
||
|
||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||
data := map[string]interface{}{
|
||
"Page": page,
|
||
"Title": title,
|
||
"CustomerName": s.cfg.Customer.Name,
|
||
"Domain": s.cfg.Customer.Domain,
|
||
"Version": s.version,
|
||
"AuthEnabled": s.authEnabled(),
|
||
"DebugMode": s.isDebug(),
|
||
// Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password,
|
||
// no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on).
|
||
"ClaimLegacyOpen": s.claimLegacyOpen(),
|
||
}
|
||
if s.alertManager != nil {
|
||
// 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
|
||
}
|
||
|
||
func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||
stackList := s.stackMgr.GetStacks()
|
||
|
||
// Filter to deployed + protected stacks first
|
||
var deployedStacks []stacks.Stack
|
||
for _, st := range stackList {
|
||
if st.Deployed || st.Protected {
|
||
deployedStacks = append(deployedStacks, st)
|
||
}
|
||
}
|
||
|
||
// Count from the DISPLAYED set only
|
||
running, stopped := 0, 0
|
||
countNow := time.Now()
|
||
for _, st := range deployedStacks {
|
||
// C9-F2: a stack that has been `restarting` past the crash-loop threshold counts with STOPPED,
|
||
// for the same reason R-51 moved `degraded` there — this counter answers "how many of my apps
|
||
// work", and an app Docker has been restarting for five minutes does not. A BRIEF restart
|
||
// still counts as running (deploys and updates pass through it), so the counter and the
|
||
// dead-app alarm now agree instead of contradicting each other on the same screen.
|
||
if st.CrashLooping(countNow) {
|
||
stopped++
|
||
continue
|
||
}
|
||
switch st.State {
|
||
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
|
||
running++
|
||
// R-51: degraded counts with stopped — the dashboard counter answers "how many of my apps
|
||
// work", and a stack with a dead supervised member does not.
|
||
case stacks.StateStopped, stacks.StateExited, stacks.StateDegraded:
|
||
stopped++
|
||
}
|
||
}
|
||
|
||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||
|
||
data := s.baseData("dashboard", "Vezérlőpult")
|
||
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
|
||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||
// R-560: the app rows carry catalog copy (each row's `.Meta.Description`), so the list the
|
||
// TEMPLATE sees is the localised view. The lists the warning helpers below see are the
|
||
// originals — they key on stack NAMES, and handing them a translated copy would only widen
|
||
// what a catalog push can reach. For hu this returns the same slice, untouched.
|
||
data["Stacks"] = stacks.LocalizeStacks(deployedStacks, s.langFor(r))
|
||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||
data["OOMKilled"] = s.stackMgr.OOMKilledStacks() // R-514
|
||
nw, ns := s.networkStorageWarnings(deployedStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
|
||
data["NetworkWarnings"] = nw
|
||
data["NetworkStubs"] = ns
|
||
data["RunningCount"] = running
|
||
data["StoppedCount"] = stopped
|
||
data["TotalCount"] = len(stackList)
|
||
data["SystemInfo"] = sysInfo
|
||
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.
|
||
data["DiskHealthRows"] = s.diskHealthRows(r.Context())
|
||
|
||
// Backup status
|
||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||
if s.backupMgr != nil {
|
||
nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow())
|
||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||
data["DBDumpStatus"] = fullStatus.LastDBDump
|
||
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on
|
||
// .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box
|
||
// rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly
|
||
// onto the template's branch, so a fresh box still reads "Még nem futott" honestly.
|
||
data["BackupStatus"] = fullStatus.LastDBDump
|
||
data["BackupRunning"] = fullStatus.Running
|
||
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
|
||
}
|
||
|
||
// Build subdomain map for "Megnyitás" buttons
|
||
data["Subdomains"] = s.subdomainMap(deployedStacks)
|
||
|
||
if s.alertManager != nil {
|
||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard", s.langFor(r))
|
||
}
|
||
|
||
s.executeTemplate(w, r, "dashboard", data)
|
||
}
|
||
|
||
// subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard
|
||
// + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it
|
||
// replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the
|
||
// well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry.
|
||
func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string {
|
||
subdomains := make(map[string]string)
|
||
for _, stack := range stackList {
|
||
if stack.Deployed {
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
subdomains[stack.Name] = sd
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
if stack.Meta.Subdomain != "" {
|
||
subdomains[stack.Name] = stack.Meta.Subdomain
|
||
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
|
||
subdomains[stack.Name] = sd
|
||
}
|
||
}
|
||
return subdomains
|
||
}
|
||
|
||
// controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a
|
||
// launchable app even if a subdomain mapping somehow exists for it.
|
||
const controllerStackName = "felhom-controller"
|
||
|
||
// LauncherApp is one openable app tile for the Indítópult (launcher) page.
|
||
type LauncherApp struct {
|
||
Name string
|
||
DisplayName string
|
||
Slug string
|
||
State stacks.ContainerState
|
||
Subdomain string
|
||
OpenPath string
|
||
BrandColor string
|
||
}
|
||
|
||
// buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the
|
||
// subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a
|
||
// subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the
|
||
// grid order is stable regardless of Go's map iteration order.
|
||
func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp {
|
||
var apps []LauncherApp
|
||
for _, st := range stackList {
|
||
if st.Name == controllerStackName {
|
||
continue
|
||
}
|
||
sd, ok := subdomains[st.Name]
|
||
if !ok || sd == "" {
|
||
continue
|
||
}
|
||
dn := st.Meta.DisplayName
|
||
if dn == "" {
|
||
dn = st.Name
|
||
}
|
||
apps = append(apps, LauncherApp{
|
||
Name: st.Name,
|
||
DisplayName: dn,
|
||
Slug: st.Meta.Slug,
|
||
State: st.State,
|
||
Subdomain: sd,
|
||
OpenPath: st.Meta.OpenPath,
|
||
BrandColor: st.Meta.BrandColor,
|
||
})
|
||
}
|
||
sort.Slice(apps, func(i, j int) bool {
|
||
if apps[i].DisplayName == apps[j].DisplayName {
|
||
return apps[i].Name < apps[j].Name
|
||
}
|
||
return apps[i].DisplayName < apps[j].DisplayName
|
||
})
|
||
return apps
|
||
}
|
||
|
||
// launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain,
|
||
// controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders
|
||
// the EXACT same app slice as the admin launcher.
|
||
func (s *Server) launcherApps() []LauncherApp {
|
||
var eligible []stacks.Stack
|
||
for _, st := range s.stackMgr.GetStacks() {
|
||
if st.Deployed || st.Protected {
|
||
eligible = append(eligible, st)
|
||
}
|
||
}
|
||
return buildLauncherApps(eligible, s.subdomainMap(eligible))
|
||
}
|
||
|
||
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
|
||
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
|
||
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult. It also carries the
|
||
// "Indítópult megosztása" share state (v0.165.0) for the modal.
|
||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.baseData("launcher", "Indítópult")
|
||
data["TitleKey"] = "page.title.launcher" // i18n: the Hungarian title above is what hu renders
|
||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||
data["Apps"] = s.launcherApps()
|
||
|
||
// Share modal state. The share URL is built from the request Host at render time (the canonical
|
||
// controller subdomain is not persisted anywhere reachable here); it carries the live token, which
|
||
// is fine to show the authed admin inside the modal — the ONE admin surface allowed to reveal it.
|
||
token := s.settings.GetLauncherShareToken()
|
||
data["ShareEnabled"] = token != ""
|
||
if token != "" {
|
||
data["ShareURL"] = "https://" + r.Host + "/s/" + token
|
||
}
|
||
data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != ""
|
||
if f := s.flashFrom(r, "flash"); f != "" {
|
||
data["ShareFlash"] = f
|
||
}
|
||
s.executeTemplate(w, r, "launcher", data)
|
||
}
|
||
|
||
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
|
||
// `hidden` or `abandoned`) AND are not deployed on this box.
|
||
//
|
||
// The `Deployed || Protected` half is the load-bearing part: a customer already running an app must
|
||
// keep seeing and managing it, whatever the catalog now says about offering it to new customers.
|
||
// Withdrawing an app must never take a working app away from someone — that is precisely the failure
|
||
// the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field
|
||
// rather than a deletion.
|
||
//
|
||
// Filtered here, in the handler, rather than in the template: the template already carries five
|
||
// conditional badges per card, and a visibility rule buried among them is a rule nobody can test.
|
||
func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack {
|
||
out := make([]stacks.Stack, 0, len(in))
|
||
for _, st := range in {
|
||
if st.Deployed || st.Protected || st.Meta.CanInstall() {
|
||
out = append(out, st)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.baseData("stacks", "Alkalmazások")
|
||
data["TitleKey"] = "page.title.stacks" // i18n: the Hungarian title above is what hu renders
|
||
allStacks := visibleCatalogStacks(s.stackMgr.GetStacks())
|
||
data["Stacks"] = stacks.LocalizeStacks(allStacks, s.langFor(r)) // R-560 — see dashboardHandler
|
||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||
nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
|
||
data["NetworkWarnings"] = nw
|
||
data["NetworkStubs"] = ns
|
||
|
||
// Build storage label lookup for deployed apps
|
||
storageLabels := make(map[string]string) // stack name → storage label
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
|
||
for _, sp := range storagePaths {
|
||
if sp.Path == hddPath {
|
||
storageLabels[stack.Name] = sp.Label
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["StorageLabels"] = storageLabels
|
||
|
||
// Build effective subdomain lookup (stored env > metadata > well-known fallback)
|
||
data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks())
|
||
|
||
s.executeTemplate(w, r, "stacks", data)
|
||
}
|
||
|
||
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||
stack, ok := s.stackMgr.GetStack(name)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
logs, err := s.stackMgr.GetLogs(name, 200)
|
||
if err != nil {
|
||
logs = s.msg(r, "logs.fetch_failed", err)
|
||
}
|
||
|
||
// Raw mode: return plain text for AJAX polling
|
||
if r.URL.Query().Get("raw") == "1" {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
fmt.Fprint(w, logs)
|
||
return
|
||
}
|
||
|
||
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"] = stacks.LocalizeStackPtr(stack, s.langFor(r)) // R-560
|
||
data["Logs"] = logs
|
||
s.executeTemplate(w, r, "logs", data)
|
||
}
|
||
|
||
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
|
||
}
|
||
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
|
||
if err != nil {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
|
||
}
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
stack, _ := s.stackMgr.GetStack(name)
|
||
alreadyDeployed := appCfg != nil && appCfg.Deployed
|
||
|
||
// R-560 — the ONE place this page's catalog copy is chosen, and it is placed here on purpose:
|
||
// every `meta` read below it (the field labels, the descriptions, the optional-config groups)
|
||
// is a display read, and this handler only ever renders — the deploy POST is a different
|
||
// route. The overlay changes copy fields ONLY; `env_var`, `type`, `generate`, `default`,
|
||
// `required` and `locked_after_deploy` are not in MetadataOverlay at all, so the auto-field
|
||
// map, the HDD_PATH question and the prefill keys below read exactly what they read before.
|
||
lang := s.langFor(r)
|
||
localized := meta.For(lang)
|
||
meta = &localized
|
||
|
||
pageTitle, pageTitleKey := meta.DisplayName+" — Telepítés", "page.title.deploy"
|
||
if alreadyDeployed {
|
||
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"] = stacks.LocalizeStackPtr(stack, lang) // R-560
|
||
data["Meta"] = meta
|
||
data["AppConfig"] = appCfg
|
||
data["AlreadyDeployed"] = alreadyDeployed
|
||
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
|
||
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
||
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
||
data["UserFields"] = meta.UserFacingFields()
|
||
data["AutoFields"] = meta.AutoGeneratedFields()
|
||
// Auto-generated field values: existing values for deployed apps, pre-generated for new deploys
|
||
autoFieldValues := make(map[string]string)
|
||
var decryptedEnv map[string]string
|
||
if appCfg != nil {
|
||
decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
|
||
}
|
||
if alreadyDeployed && appCfg != nil {
|
||
for _, f := range meta.AutoGeneratedFields() {
|
||
if val, ok := decryptedEnv[f.EnvVar]; ok {
|
||
autoFieldValues[f.EnvVar] = val
|
||
}
|
||
}
|
||
} else if !alreadyDeployed {
|
||
// Pre-generate values so the user sees (and can note down) domain/passwords before deploying.
|
||
// These same values are submitted back in the form and saved to app.yaml.
|
||
if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil {
|
||
autoFieldValues = preview
|
||
}
|
||
}
|
||
data["AutoFieldValues"] = autoFieldValues
|
||
// For deployed apps, pass stored field values (decrypted) so fields show current values
|
||
if alreadyDeployed && decryptedEnv != nil {
|
||
data["DeployedFieldValues"] = decryptedEnv
|
||
}
|
||
// R-351 SCENARIO A — an app being reinstalled so its data can come back should not ask the
|
||
// customer to remember what their own backup already recorded. The address and the data folder
|
||
// are read from the most readable recovery unit (local file reads; no network, no restore) and
|
||
// offered as a PREFILL the customer may change — a memory, not a lock.
|
||
//
|
||
// Only for a NOT-deployed app: on the ordinary path (installed, unchanged) nothing here runs and
|
||
// the page is byte-identical to before. Scenario D is protected by that condition, not by luck.
|
||
//
|
||
// An UNKNOWN is never rendered as a value. RecordedAddress.Known() requires BOTH halves, because
|
||
// the live deploy path substitutes the catalog's default subdomain — a guess, not the customer's
|
||
// answer — and offering that back as "what your backup says" would fabricate a fact.
|
||
//
|
||
// Every key below is set UNCONDITIONALLY (to its zero value when there is no record), because a
|
||
// Go template that does `index` or `eq` against an undefined key errors at RENDER time — green
|
||
// build, green vet, green suite, 500 on the page. That trap is on file in this repo twice.
|
||
declaresDataPath := meta.HasDeployField("HDD_PATH")
|
||
data["RestoreFieldValues"] = map[string]string{}
|
||
data["RestorePrefillHDDPath"] = ""
|
||
data["RestoreRecordedDrive"] = ""
|
||
data["RestoreRecordedAddress"] = ""
|
||
data["RestoreRecordedDeclaresPath"] = declaresDataPath
|
||
data["RestoreHasRecord"] = false
|
||
// Part 1's visibility line needs the real path, not a literal in a template.
|
||
data["SystemDataPath"] = s.cfg.Paths.SystemDataPath
|
||
if !alreadyDeployed && s.backupMgr != nil {
|
||
if place, addr, ok := s.backupMgr.RecordedUnitForStack(name); ok {
|
||
prefill := map[string]string{}
|
||
if addr.Known() {
|
||
prefill["SUBDOMAIN"] = addr.Subdomain
|
||
prefill["DOMAIN"] = addr.Domain
|
||
}
|
||
// The folder is offered as a VALUE only when this app actually has a field for it. The
|
||
// 40-of-53 apps that declare no data path have nothing to change — for them the placement
|
||
// is stated as a fact, never written into an input that does not exist.
|
||
if place.Known() && declaresDataPath {
|
||
prefill["HDD_PATH"] = place.Drive
|
||
data["RestorePrefillHDDPath"] = place.Drive
|
||
}
|
||
data["RestoreFieldValues"] = prefill
|
||
data["RestoreRecordedDrive"] = place.Drive
|
||
data["RestoreRecordedAddress"] = addr.FQDN()
|
||
data["RestoreHasRecord"] = place.Known() || addr.Known()
|
||
}
|
||
}
|
||
// Storage paths with free space info for deploy dropdown
|
||
var deployPaths []DeployStoragePath
|
||
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
||
dp := DeployStoragePath{StoragePath: sp}
|
||
// R-108: mark, do not hide. The server-side refusal in the deploy POST is the boundary; this is
|
||
// 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 = s.msg(r, "deploy.path_not_allowed")
|
||
}
|
||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||
if di.TotalGB > 0 {
|
||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||
}
|
||
}
|
||
deployPaths = append(deployPaths, dp)
|
||
}
|
||
data["StoragePaths"] = deployPaths
|
||
// RCA fix 4: a deployed app's read-only storage select must show the app's STORED HDD_PATH —
|
||
// never the default drive (the pre-fix render selected by IsDefault only, so the settings view
|
||
// lied about where the data lives). When the stored path is no longer schedulable, an extra
|
||
// disabled option names it verbatim rather than silently showing a different storage.
|
||
data["CurrentHDDPath"] = ""
|
||
data["CurrentHDDPathMissing"] = false
|
||
if alreadyDeployed && appCfg != nil {
|
||
if hdd := appCfg.Env["HDD_PATH"]; hdd != "" {
|
||
data["CurrentHDDPath"] = hdd
|
||
inList := false
|
||
for _, dp := range deployPaths {
|
||
if dp.Path == hdd {
|
||
inList = true
|
||
break
|
||
}
|
||
}
|
||
data["CurrentHDDPathMissing"] = !inList
|
||
}
|
||
}
|
||
|
||
// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
|
||
// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
|
||
// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
|
||
if !alreadyDeployed {
|
||
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
|
||
data["DockerBelowReserve"] = hr.BelowReserve
|
||
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
|
||
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
|
||
}
|
||
}
|
||
|
||
// Effective subdomain for "Megnyitás" button
|
||
if alreadyDeployed && appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
data["EffectiveSubdomain"] = sd
|
||
}
|
||
}
|
||
|
||
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
|
||
// backup) has moved to the host agent (slice 8C); the deploy page no longer
|
||
// renders those sections.
|
||
if alreadyDeployed {
|
||
// App-to-app integrations
|
||
if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil {
|
||
data["HasIntegrations"] = true
|
||
// R-560: the integration manager reads the stack's own (Hungarian) Meta to build these
|
||
// rows — it has no request and therefore no language. The label and the sentence are
|
||
// catalog copy, so they are re-taken from the LOCALISED meta here, matched by target
|
||
// the same way the overlay itself matches. Everything else in the row (state, target
|
||
// health, last error) is the manager's and is left alone.
|
||
rows := im.ListForProvider(meta.Slug)
|
||
for i := range rows {
|
||
for _, def := range meta.Integrations {
|
||
if def.Target == rows[i].Target {
|
||
rows[i].Label, rows[i].Description = def.Label, def.Description
|
||
break
|
||
}
|
||
}
|
||
}
|
||
data["Integrations"] = rows
|
||
}
|
||
|
||
// Geo-restriction per-app data
|
||
geo := s.settings.GetGeoRestriction()
|
||
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
|
||
data["GeoGlobalEnabled"] = true
|
||
data["GeoGlobalCountries"] = geo.AllowedCountries
|
||
if ov, ok := geo.AppOverrides[name]; ok {
|
||
data["GeoAppOverride"] = true
|
||
data["GeoAppOverrideCountries"] = ov.AllowedCountries
|
||
} else {
|
||
data["GeoAppOverrideCountries"] = []string{}
|
||
}
|
||
} else {
|
||
data["GeoGlobalCountries"] = []string{}
|
||
data["GeoAppOverrideCountries"] = []string{}
|
||
}
|
||
|
||
// Optional config (metadata providers, etc.)
|
||
if meta.HasOptionalConfig() {
|
||
data["HasOptionalConfig"] = true
|
||
data["OptionalConfig"] = meta.OptionalConfig
|
||
optValues := make(map[string]string)
|
||
if decryptedEnv != nil {
|
||
for _, group := range meta.OptionalConfig {
|
||
for _, field := range group.Fields {
|
||
if val, ok := decryptedEnv[field.EnvVar]; ok {
|
||
optValues[field.EnvVar] = val
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["CurrentValues"] = optValues
|
||
}
|
||
|
||
// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
|
||
// honest context whether or not the global toggle is on.
|
||
if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
|
||
data["AppEmailSupported"] = true
|
||
data["AppEmailAppOn"] = enabled
|
||
data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
|
||
local := meta.SMTPMapping.FromLocal
|
||
if local == "" {
|
||
local = meta.Slug
|
||
}
|
||
domain := "felhom.eu"
|
||
if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
|
||
domain = s.cfg.MailRelay.FromDomains[0]
|
||
}
|
||
data["AppEmailFromAddress"] = local + "@" + domain
|
||
}
|
||
}
|
||
|
||
// Memory info for deploy page (only for non-deployed apps)
|
||
if !alreadyDeployed {
|
||
memInfo := map[string]interface{}{"Available": false}
|
||
totalMB, usedMB, memErr := system.GetMemoryMB()
|
||
if memErr == nil {
|
||
reservedMB := s.cfg.System.ReservedMemoryMB
|
||
usableMB := totalMB - reservedMB
|
||
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
|
||
afterMB := usedMB + newReqMB
|
||
percent := 0
|
||
if usableMB > 0 {
|
||
percent = afterMB * 100 / usableMB
|
||
}
|
||
usedPercent := 0
|
||
if usableMB > 0 {
|
||
usedPercent = usedMB * 100 / usableMB
|
||
}
|
||
|
||
// Overcommit warning still uses declared limits
|
||
_, committedLimitMB := s.stackMgr.CommittedMemory()
|
||
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
|
||
afterLimitMB := committedLimitMB + newLimitMB
|
||
|
||
memInfo["Available"] = true
|
||
memInfo["TotalMB"] = totalMB
|
||
memInfo["ReservedMB"] = reservedMB
|
||
memInfo["UsableMB"] = usableMB
|
||
memInfo["UsedMB"] = usedMB
|
||
memInfo["NewRequestMB"] = newReqMB
|
||
memInfo["AfterMB"] = afterMB
|
||
memInfo["Percent"] = percent
|
||
memInfo["UsedPercent"] = usedPercent
|
||
memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB
|
||
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
|
||
}
|
||
data["MemoryInfo"] = memInfo
|
||
}
|
||
|
||
// Flash messages from cross-drive backup save redirect
|
||
if flash := s.flashFrom(r, "flash"); flash != "" {
|
||
data["FlashSuccess"] = flash
|
||
}
|
||
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
|
||
data["FlashError"] = flashErr
|
||
}
|
||
|
||
s.executeTemplate(w, r, "deploy", data)
|
||
}
|
||
|
||
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||
var found *stacks.Stack
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if stack.Meta.Slug == slug {
|
||
found = &stack
|
||
break
|
||
}
|
||
}
|
||
if found == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// Determine effective subdomain (stored env > metadata fallback)
|
||
effectiveSubdomain := found.Meta.Subdomain
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
effectiveSubdomain = sd
|
||
}
|
||
}
|
||
|
||
// R-560 — this page is the one that shows ALL of an app's catalog copy (tagline, use cases,
|
||
// first steps, prerequisites, the default-credentials line, the data-folder labels), so the
|
||
// localised view is taken ONCE here and `found` is replaced by it. Everything downstream —
|
||
// the data-path cards, the initial-credentials note — then reads the household's language
|
||
// without a second decision to get wrong.
|
||
found = stacks.LocalizeStackPtr(found, s.langFor(r))
|
||
|
||
data := s.baseData("stacks", found.Meta.DisplayName)
|
||
data["Stack"] = found
|
||
data["Meta"] = found.Meta
|
||
data["AppInfo"] = found.Meta.AppInfo
|
||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||
|
||
// „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY,
|
||
// like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0
|
||
// app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did.
|
||
if cards := s.buildDataPathCards(found); len(cards) > 0 {
|
||
data["DataPathCards"] = cards
|
||
}
|
||
|
||
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
|
||
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
|
||
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
|
||
//
|
||
// ⚠ R-254 (v0.208.0) — THE PASSWORD DOES NOT GO INTO THE PAGE DATA, AND THAT IS THE WHOLE FIX.
|
||
//
|
||
// Until v0.208.0 this handed the whole struct to the template, which rendered the password into
|
||
// `<span id="initcred-pw-val" hidden>…</span>`. `hidden` is an attribute the browser honours when
|
||
// DRAWING; the plaintext was in the response body of every render, so a `curl` of an app's info
|
||
// page returned a real per-install credential. Identical in shape to R-249 one page over, and this
|
||
// one is an app the customer actually logs into.
|
||
//
|
||
// What travels now is the non-secret half (username, note) plus a BOOLEAN. The value is fetched by
|
||
// POST /apps/<slug>/initial-credentials/reveal, which re-reads it LIVE from the container — see
|
||
// §7.1: caching it here would put it straight back where it started, one layer in.
|
||
if found.Deployed && found.Meta.InitialCreds != nil {
|
||
if creds, err := s.readInitialCreds(found.Name); err != nil {
|
||
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
|
||
} else if creds != nil && creds.Available {
|
||
data["InitialCreds"] = &stacks.ExtractedCreds{
|
||
Available: creds.Available,
|
||
Username: creds.Username,
|
||
// R-560: the reader took the note from the manager's Hungarian metadata (it runs
|
||
// without a request and has no language). The note is catalog copy, so it is
|
||
// re-taken from the localised spec — the same value for hu, byte for byte.
|
||
Note: found.Meta.InitialCreds.Note,
|
||
// Password deliberately NOT carried — the reveal endpoint is the only path to it.
|
||
}
|
||
data["InitialCredsHasPassword"] = strings.TrimSpace(creds.Password) != ""
|
||
}
|
||
}
|
||
|
||
// R-513 (v0.243.0): the file manager's login lives with the other app logins. The controller set a
|
||
// generated password (state "generated") or found one set by hand ("operator"). Same R-254 rule:
|
||
// only the username and a boolean reach the page; the value comes from the reveal endpoint.
|
||
if found.Name == fileBrowserStack && s.settings != nil {
|
||
state, enc, _ := s.settings.GetFileBrowserAdmin()
|
||
switch state {
|
||
case settings.FileBrowserAdminGenerated:
|
||
data["HasAppInfo"] = true
|
||
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin",
|
||
Note: s.msg(r, "creds.filebrowser_note")}
|
||
data["InitialCredsHasPassword"] = enc != ""
|
||
case settings.FileBrowserAdminOperator:
|
||
data["HasAppInfo"] = true
|
||
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin"}
|
||
data["InitialCredsOperatorSet"] = true
|
||
}
|
||
}
|
||
|
||
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
|
||
if found.Deployed {
|
||
current := ""
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||
current = appCfg.Env["HDD_PATH"]
|
||
}
|
||
var targets []settings.StoragePath
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
|
||
continue
|
||
}
|
||
// R-108: never OFFER network storage as a migrate target — an app namespace may not live
|
||
// there. Dropped rather than shown-disabled: unlike the deploy page this list has no
|
||
// explanatory surface, and a target that cannot be chosen is not a target. The refusal that
|
||
// MATTERS is server-side in handleStorageMigrateApp; this only keeps the UI honest.
|
||
if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse {
|
||
continue
|
||
}
|
||
targets = append(targets, sp)
|
||
}
|
||
data["MigrateTargets"] = targets
|
||
data["MigrateCurrent"] = current
|
||
if label, missing := s.missingStorageLabel(current); missing {
|
||
data["MissingStorageLabel"] = label
|
||
}
|
||
}
|
||
|
||
s.executeTemplate(w, r, "app_info", data)
|
||
}
|
||
|
||
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(s.langFor(r))
|
||
|
||
if s.alertManager != nil {
|
||
data["Alerts"] = s.alertManager.GetAlerts(s.langFor(r))
|
||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring", s.langFor(r))
|
||
}
|
||
|
||
// Hub connection status section
|
||
data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != ""
|
||
data["HubURL"] = s.cfg.Hub.URL
|
||
data["CustomerID"] = s.cfg.Customer.ID
|
||
|
||
if s.hubPushStatusFn != nil {
|
||
ps := s.hubPushStatusFn()
|
||
data["HubLastAttempt"] = ps.LastAttempt
|
||
data["HubLastSuccess"] = ps.LastSuccess
|
||
data["HubLastError"] = ps.LastError
|
||
data["HubConsecutiveFailures"] = ps.Consecutive
|
||
// Connected if last success was within 2x the push interval (or 30min default)
|
||
connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute
|
||
data["HubConnected"] = connected
|
||
}
|
||
|
||
// Legacy ping status section (still shown for backward compat during transition)
|
||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||
if s.cfg.Monitoring.Enabled {
|
||
pings := []map[string]interface{}{
|
||
{"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 {
|
||
if !p["Configured"].(bool) {
|
||
allConfigured = false
|
||
break
|
||
}
|
||
}
|
||
data["PingStatus"] = pings
|
||
data["AllPingsConfigured"] = allConfigured
|
||
}
|
||
|
||
s.executeTemplate(w, r, "monitoring", data)
|
||
}
|
||
|
||
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
|
||
func isPingConfigured(uuid string) bool {
|
||
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
|
||
}
|
||
|
||
// backupsCommonData builds what every backups sub-page shares (v0.124.0 IA split): the page
|
||
// chrome + the backup full-status with the redirect flash. Backup stays nil (empty-state) when
|
||
// the manager is absent. Each page handler adds ONLY the data its sections render — no
|
||
// duplicated computation across the four pages.
|
||
func (s *Server) backupsCommonData(page, title string, r *http.Request) map[string]interface{} {
|
||
data := s.baseData(page, title)
|
||
if s.backupMgr == nil {
|
||
data["Backup"] = nil
|
||
return data
|
||
}
|
||
nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow())
|
||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||
|
||
// Pass flash messages from query params (set by redirect handlers)
|
||
if flash := s.flashFrom(r, "flash"); flash != "" {
|
||
fullStatus.FlashSuccess = flash
|
||
}
|
||
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
|
||
fullStatus.FlashError = flashErr
|
||
}
|
||
data["Backup"] = fullStatus
|
||
return data
|
||
}
|
||
|
||
// 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{}, lang string) {
|
||
offboxTgt := s.settings.GetOffboxTarget()
|
||
data["Offbox"] = offboxTgt
|
||
data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured()
|
||
// R-70 (v0.161.0): hub-managed offsite is enabled in controller.yaml — the descriptor exists
|
||
// and the apply-bridge WILL configure the target automatically. The empty state must say so
|
||
// instead of the bare "nincs beállítva" (a box stuck pre-apply looked identical to one that
|
||
// was never provisioned — DIAG-f10: that ambiguity hid a burned credential for 2 days).
|
||
data["OffsiteHubEnabled"] = s.cfg != nil && s.cfg.Offsite.Enabled
|
||
offboxApps := s.buildOffboxApps()
|
||
data["OffboxApps"] = offboxApps
|
||
// Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is
|
||
// actually covered by the offsite leg until the customer toggles at least one.
|
||
offboxToggled := 0
|
||
for _, a := range offboxApps {
|
||
if a.Enabled {
|
||
offboxToggled++
|
||
}
|
||
}
|
||
data["OffboxToggledCount"] = offboxToggled
|
||
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
|
||
if offboxTgt != nil {
|
||
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(offboxTgt.LastWarning, offboxTgt.LastWarningKind, offboxToggled, lang)
|
||
} else {
|
||
data["OffboxWarningDisplay"] = ""
|
||
}
|
||
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
|
||
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
|
||
// 3a: per-app "config+DB only" note set — apps whose enlarged push the quota gate blocked last run.
|
||
blocked := map[string]bool{}
|
||
if offboxTgt != nil {
|
||
for _, a := range offboxTgt.EnlargedBlocked {
|
||
blocked[a] = true
|
||
}
|
||
}
|
||
data["OffboxBlockedSet"] = blocked
|
||
}
|
||
|
||
// offboxStaleWarningMarker is the substring the zero-toggled offbox run wrote into LastWarning before
|
||
// 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"
|
||
offboxSelectionChangedKey = "offbox.selection_changed"
|
||
)
|
||
|
||
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
|
||
// Offbox.LastWarning. A zero-toggled run records "Sikeres — nincs mentésre jelölt
|
||
// alkalmazás…"; once the customer HAS toggled apps that line is stale and misleading —
|
||
// replace it with the honest "selection changed, the next run covers it" note.
|
||
// Pure display logic: the persisted LastWarning is never touched, and every other
|
||
// warning (quota, partial failure) passes through verbatim.
|
||
// R-553 — the decision is made on the KIND the run recorded, not on the words of the sentence.
|
||
// The one text test that remains runs ONLY when there is no kind, i.e. on a box whose last off-site
|
||
// run happened before v0.251.0 and whose persisted warning therefore predates kinds.
|
||
//
|
||
// 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 (s *Server) offboxWarningDisplay(lastWarning, kind string, toggledCount int, lang string) string {
|
||
if toggledCount < 1 {
|
||
return lastWarning
|
||
}
|
||
if kind == backup.OffboxWarnNoAppsSelected {
|
||
return s.msgLang(lang, offboxSelectionChangedKey)
|
||
}
|
||
if kind == "" && strings.Contains(lastWarning, offboxStaleWarningMarker) {
|
||
return s.msgLang(lang, offboxSelectionChangedKey)
|
||
}
|
||
return lastWarning
|
||
}
|
||
|
||
// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and
|
||
// the status stat cards.
|
||
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups", "Biztonsági mentés", r)
|
||
data["TitleKey"] = "page.title.backups" // i18n: the Hungarian title above is what hu renders
|
||
|
||
// System info for storage overview bars
|
||
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||
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())
|
||
|
||
// R-112: the backup-target banner finally has a consumer. nil in the healthy and unknown states,
|
||
// so the template renders nothing at all — no badge, no reassurance. This is the seam whose
|
||
// absence made E-2's degraded banner and offer invisible to every customer.
|
||
data["BackupTarget"] = s.backupTargetView(r.Context())
|
||
|
||
// Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times.
|
||
s.backupWindowData(data)
|
||
|
||
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
|
||
// DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.):
|
||
// "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded".
|
||
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
|
||
}
|
||
|
||
s.executeTemplate(w, r, "backups", data)
|
||
}
|
||
|
||
// escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is
|
||
// shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive"
|
||
// warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm
|
||
// (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely
|
||
// stuck ceremony never renders as an indefinite wait.
|
||
const escrowCeremonyGraceWindow = 35 * time.Minute
|
||
|
||
// offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card:
|
||
// awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window,
|
||
// still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the
|
||
// plain pending CTA rather than render a phantom wait.
|
||
func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) {
|
||
if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" {
|
||
return false, false
|
||
}
|
||
ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt)
|
||
if err != nil {
|
||
return false, false
|
||
}
|
||
if time.Since(ts) >= escrowCeremonyGraceWindow {
|
||
return false, true
|
||
}
|
||
return true, false
|
||
}
|
||
|
||
// backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the
|
||
// participation toggles and the manual-target form.
|
||
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.langFor(r))
|
||
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
|
||
data["EscrowStale"] = s.escrowStale()
|
||
agentVer := ""
|
||
if agent, err := s.escrowAgentConn(); err == nil {
|
||
agentVer = agent.AgentVersion()
|
||
}
|
||
data["EscrowAgentOK"] = escrowAgentSupported(agentVer)
|
||
// v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) —
|
||
// bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip.
|
||
awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget())
|
||
data["OffboxCeremonyAwaiting"] = awaiting
|
||
data["OffboxCeremonyTimedOut"] = timedOut
|
||
// v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger.
|
||
data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
|
||
// R-193: the PERMANENT entry point to the recovery screen. It is bound to recoveryOffer, NOT to
|
||
// recoveryInterrupts — "most nem" silences the full-page interruption and must never remove the
|
||
// route to the data. A one-shot notice a flustered person clicks past is a notice that never
|
||
// happened; this is what makes Scenario E true.
|
||
data["RecoveryOffer"] = s.recoveryOffer()
|
||
// R-241 (v0.206.0): the abandonment countdown, stated on the page the customer chose it from.
|
||
// It is shown for the WHOLE window, not only at the reminder marks — the bar on other pages is a
|
||
// nudge, this is the record, and a deletion date must be findable on a quiet day too.
|
||
if s.backupMgr != nil {
|
||
if st := s.backupMgr.AbandonStatus(); st.Active {
|
||
data["AbandonActive"] = true
|
||
data["AbandonDaysLeft"] = st.DaysLeft
|
||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||
// R-302: this block makes the SAME retrieval promise as the banner, under a different verb
|
||
// („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth
|
||
// instance nobody had counted. Same single derivation — fixing one surface and not the other
|
||
// would leave the page contradicting the strip above it.
|
||
data["AbandonRetrievalOffered"] = st.RetrievalStillOffered
|
||
} else if st.PurgeRequested {
|
||
// The store is deleted and the sealed package is on its way out. Say so rather than
|
||
// showing nothing, or the page silently loses a thing the customer was watching.
|
||
data["AbandonPurging"] = true
|
||
}
|
||
}
|
||
s.addRecoveryBanner(data, r)
|
||
s.executeTemplate(w, r, "backups_remote", data)
|
||
}
|
||
|
||
// backupsAppsHandler renders the Alkalmazások page: schedule, databases and the per-app
|
||
// 1./2./3. tier rows.
|
||
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, 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
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
for i := range fullStatus.AppDataInfo {
|
||
app := &fullStatus.AppDataInfo[i]
|
||
if len(app.HDDPaths) > 0 {
|
||
hddPath := app.HDDPaths[0].HostPath
|
||
// Match HDD path prefix against registered storage paths
|
||
for _, sp := range storagePaths {
|
||
if strings.HasPrefix(hddPath, sp.Path) {
|
||
app.StorageLabel = sp.Label
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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, s.langFor(r))
|
||
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
|
||
}
|
||
|
||
s.executeTemplate(w, r, "backups_apps", data)
|
||
}
|
||
|
||
// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox
|
||
// restore-to-verify list and the .fab export/import loop.
|
||
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r)
|
||
data["TitleKey"] = "page.title.backups_restore" // i18n: the Hungarian title above is what hu renders
|
||
// R-550: restores the box was running when it stopped — shown until that app is restored again.
|
||
if s.backupMgr != nil {
|
||
data["InterruptedRestores"] = s.backupMgr.InterruptedRestores()
|
||
}
|
||
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 != "" {
|
||
data["FullPrepApp"] = fp
|
||
data["FullPrepSize"] = r.URL.Query().Get("full_size")
|
||
}
|
||
// Per-app place-to-live availability (a completed full scratch exists → offer the merge action).
|
||
ready := map[string]bool{}
|
||
if s.backupMgr != nil {
|
||
if apps, ok := data["OffboxApps"].([]OffboxAppRow); ok {
|
||
for _, a := range apps {
|
||
if a.Enabled && s.backupMgr.OffboxFullScratchReady(a.Name) {
|
||
ready[a.Name] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["OffboxScratchReady"] = ready
|
||
// R-43: the same prepared scratch also enables the TRUE restore (files + database). Its confirm
|
||
// has to state what the pair actually IS — how old the DB half is, whether the two halves even
|
||
// come from the same run, and whether the dump looks customer-empty — because a restore is the
|
||
// one operation whose result the customer cannot inspect until after committing to it.
|
||
pairs := map[string]backup.OffsitePairInfo{}
|
||
if s.backupMgr != nil {
|
||
for name := range ready {
|
||
pairs[name] = s.backupMgr.OffsiteScratchPair(name)
|
||
}
|
||
}
|
||
data["OffboxPairInfo"] = pairs
|
||
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
|
||
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
|
||
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
|
||
if s.backupMgr != nil {
|
||
data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0
|
||
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
|
||
data["SharesDisplayName"] = backup.SharesDisplayName
|
||
}
|
||
// v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any
|
||
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
|
||
if s.backupMgr != nil {
|
||
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
|
||
// R-487: removed apps whose unit was kept join the local restore picker.
|
||
data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits()
|
||
}
|
||
// R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt
|
||
// box has neither, and used to be shown nothing at all while its snapshots sat in the repository.
|
||
if s.backupMgr != nil {
|
||
rows, state := s.offsiteRestoreRows(r.Context())
|
||
data["OffsiteRestoreRows"] = rows
|
||
data["OffsiteStoreState"] = string(state)
|
||
// R-252: a rebuilt box's drives survive but their REGISTRATION does not, and every restore
|
||
// then refuses with a message that names no next step. Asked through the backup manager's own
|
||
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
|
||
// template renders exactly as before (Scenario E).
|
||
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
|
||
// R-280: the notice above told the customer this was „két kattintás" and pointed at a picker
|
||
// that was empty, so it was zero clicks. The promise is now conditional on the destination it
|
||
// points at actually having something in it — and when it does not, the page says so and names
|
||
// what to do instead. Same derivation the picker uses, so the two cannot disagree.
|
||
data["HasAttachDestination"] = len(s.attachableStores()) > 0
|
||
}
|
||
s.executeTemplate(w, r, "backups_restore", data)
|
||
}
|
||
|
||
// OffboxAppRow is one deployed app's off-box toggle state for the backups page.
|
||
type OffboxAppRow struct {
|
||
Name string
|
||
DisplayName string
|
||
Slug string // catalog slug for the shared app-row icon (logoURL)
|
||
Enabled bool
|
||
}
|
||
|
||
// buildOffboxApps lists deployed, non-protected apps with their off-box toggle state.
|
||
func (s *Server) buildOffboxApps() []OffboxAppRow {
|
||
var out []OffboxAppRow
|
||
if s.stackMgr == nil {
|
||
return out
|
||
}
|
||
for _, st := range s.stackMgr.GetStacks() {
|
||
if !st.Deployed || st.Protected {
|
||
continue
|
||
}
|
||
dn := st.Meta.DisplayName
|
||
if dn == "" {
|
||
dn = st.Name
|
||
}
|
||
out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, Enabled: s.settings.IsAppOffbox(st.Name)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||
type AppBackupRow struct {
|
||
StackName string
|
||
DisplayName string
|
||
Slug string // catalog slug for the aligned header icon (logoURL)
|
||
Status string // "green", "yellow", "red", "auto"
|
||
StatusText string // short Hungarian tooltip
|
||
|
||
// App characteristics
|
||
HasHDDData bool
|
||
HasDB bool
|
||
HasVolumeData bool
|
||
StorageLabel string
|
||
HDDSizeHuman string
|
||
|
||
// What this app's backup contains, PER TIER (R-537). One string for all three tiers was a claim
|
||
// no single string can support: a Tier-1 unit holds no copy of the files a class-A app keeps on
|
||
// the data drive, while Tier 2 and Tier 3 do. e.g. Tier1Contents "DB + Konfig",
|
||
// Tier23Contents "DB + Konfig + Adatok".
|
||
Tier1Contents string
|
||
Tier23Contents string
|
||
// DriveFilesNote is non-empty exactly when this app keeps files on the data drive that a Tier-1
|
||
// unit cannot hold — the one sentence that tells the household where those files ARE protected.
|
||
// Its wording follows the tier-3 STATE, not the app's shape (R-543): a copy that is paused for
|
||
// the recovery code protects nothing yet, and „védi" would be the same lie R-537 was filed for.
|
||
// DriveFilesNoteLink is the route out of that state, empty when there is nothing to press.
|
||
DriveFilesNote string
|
||
DriveFilesNoteLink string
|
||
DriveFilesNoteLinkText string
|
||
|
||
// RestoreHeld (R-379) — this app is deliberately stopped because a database restore failed AND
|
||
// the rollback failed. Distinct from any backup status: it is about the app's LIVE data, not its
|
||
// copies, which is why it drives the row red rather than yellow.
|
||
RestoreHeld bool
|
||
|
||
// Tier 1: Nightly backup (always exists)
|
||
Tier1LastRun string // RFC3339 time of the newest recovery-unit artifact ("" = no unit yet)
|
||
Tier1LastStatus string // "ok", "error", ""
|
||
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
|
||
|
||
// Tier 2: Cross-drive backup (configurable for all apps)
|
||
Tier2Configured bool
|
||
Tier2Dest string // destination label
|
||
Tier2Schedule string // "Naponta", "Hetente"
|
||
Tier2LastRun string
|
||
Tier2LastStatus string // "ok", "error", "running", ""
|
||
// R-101 — the customer may only be shown a timestamp as evidence of a COPY when a copy actually
|
||
// succeeded. Tier2LastRun is the ATTEMPT clock (written on failure too), so these three drive the
|
||
// display instead:
|
||
// Tier2LastSuccess the anchor ("" = none known)
|
||
// Tier2SuccessTracked false = this row predates the anchor → render exactly as before, once-logged
|
||
// Tier2StaleCopy the newest attempt FAILED while an older success exists → disclose both
|
||
Tier2LastSuccess string
|
||
Tier2SuccessTracked bool
|
||
Tier2StaleCopy bool
|
||
Tier2LastError string
|
||
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
|
||
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||
Tier2SizeHuman string
|
||
|
||
// R-102/R-103 — the SECOND predicate, and it is a second field on purpose.
|
||
//
|
||
// Tier2UnitRestorable the copy holds an OPENABLE recovery unit (manifest present and parseable)
|
||
// → the destructive „Teljes visszaállítás" action is offered
|
||
// Tier2CopyDate the RFC3339 stamp of the copy that action would write over live data with
|
||
// Tier2CopyDateProven false = that stamp is only an ATTEMPT clock, never a proven copy (R-101)
|
||
//
|
||
// Tier2UnitRestorable is NOT derived from Tier2LastStatus, Tier2SizeHuman or anything else on this
|
||
// row: it is computed from what the copy on disk actually holds, because a row that says a backup
|
||
// succeeded is not evidence that the package inside it can be opened.
|
||
Tier2UnitRestorable bool
|
||
Tier2CopyDate string
|
||
Tier2CopyDateProven bool
|
||
// Tier2UnitConfirm is the assembled destructive-confirm sentence (tier2UnitConfirmMsg). Built in
|
||
// Go, not in the attribute, so it is one named string a test can assert verbatim.
|
||
Tier2UnitConfirm string
|
||
// Tier2UnitStaleNotice (R-403) — non-empty when the newest run PRESERVED this copy's package
|
||
// instead of refreshing it, so the card cannot render a preserved package as a fresh one.
|
||
Tier2UnitStaleNotice string
|
||
|
||
// Drive disconnected — app's home drive is currently disconnected
|
||
DriveDisconnected bool
|
||
// Removed (R-487) — the app is NOT deployed; this row stands for a recovery unit kept on a
|
||
// drive after „Töröld az adataimat is" with the backups kept. Its one action is the unit restore,
|
||
// which reinstalls (R-253). RemovedUnitTime is the unit's newest artifact, RFC3339.
|
||
Removed bool
|
||
RemovedUnitTime string
|
||
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
||
Tier2DestDisconnected bool
|
||
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
||
Tier2DestInactive bool
|
||
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
|
||
Tier2UserDisabled bool
|
||
|
||
// Tier 3: Off-box (NAS) restic-SFTP backup — the off-site 3-2-1 leg.
|
||
OffboxEnabled bool // this app toggled for off-box inclusion (IsAppOffbox)
|
||
Tier3State string // "unconfigured" | "off" | "escrow_pending" | "active" (see tier3State)
|
||
|
||
// Warnings accumulated for this app
|
||
Warnings []string
|
||
}
|
||
|
||
// appDumpVerdict is THIS app's tier-1 verdict, from THIS app's own most recent dump result.
|
||
//
|
||
// "" (no icon) — no dump result recorded for this stack: the app has no database, or no run has
|
||
//
|
||
// happened since start-up. Presence of a restore point is NOT evidence the last
|
||
// run worked, and this is the case that used to be drawn as a green tick (R-258).
|
||
//
|
||
// "error" — this app's own most recent result carries an Error.
|
||
// "ok" — this app's own most recent result succeeded.
|
||
//
|
||
// Deliberately NOT considered: RECENCY. A tick over a three-week-old restore point is a real
|
||
// weakness, but an age threshold means inventing a number, and the time is already printed beside
|
||
// the icon. Recorded as an observation rather than changed here.
|
||
//
|
||
// Deliberately NOT used: DBDumpStatus.Success, which is the box's most recent RUN whichever app it
|
||
// belonged to. It is correct for the global tier1DBStatus label a few lines above and is the exact
|
||
// lookalike that produced this defect.
|
||
func appDumpVerdict(dump *backup.DBDumpStatus, stackName string) string {
|
||
if dump == nil {
|
||
return ""
|
||
}
|
||
verdict := ""
|
||
for _, res := range dump.Results {
|
||
if res.DB.StackName != stackName {
|
||
continue
|
||
}
|
||
// Results is one entry per DATABASE; an app may have several. Any failure among this
|
||
// app's databases makes the app's backup a failure — a partial dump is not a success.
|
||
if res.Error != nil {
|
||
return "error"
|
||
}
|
||
verdict = "ok"
|
||
}
|
||
return verdict
|
||
}
|
||
|
||
// 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, lang string) []AppBackupRow {
|
||
// Build DB stack lookup
|
||
dbStacks := make(map[string]bool)
|
||
for _, db := range status.DiscoveredDBs {
|
||
dbStacks[db.StackName] = true
|
||
}
|
||
for _, f := range status.DumpFiles {
|
||
dbStacks[f.StackName] = true
|
||
}
|
||
|
||
tier1DBStatus := ""
|
||
if status.LastDBDump != nil {
|
||
if status.LastDBDump.Success {
|
||
tier1DBStatus = "ok"
|
||
} else {
|
||
tier1DBStatus = "error"
|
||
}
|
||
}
|
||
|
||
// Build disconnected paths set for drive-disconnected detection
|
||
disconnectedPaths := make(map[string]bool)
|
||
for _, dp := range s.settings.GetDisconnectedPaths() {
|
||
disconnectedPaths[dp.Path] = true
|
||
}
|
||
|
||
// Off-box (Tier 3) globals — resolved once for all rows. The per-app state is
|
||
// (global configured) × (global escrow) × (per-app toggle); see tier3State.
|
||
offboxConfigured := s.backupMgr != nil && s.backupMgr.OffboxConfigured()
|
||
offboxEscrowState := ""
|
||
if t := s.settings.GetOffboxTarget(); t != nil {
|
||
offboxEscrowState = t.EscrowState
|
||
}
|
||
|
||
var rows []AppBackupRow
|
||
for _, app := range status.AppDataInfo {
|
||
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
||
|
||
// Check if this app's home drive is disconnected
|
||
driveDisconnected := false
|
||
if app.HasHDDData && len(app.HDDPaths) > 0 {
|
||
for dp := range disconnectedPaths {
|
||
for _, hp := range app.HDDPaths {
|
||
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
|
||
driveDisconnected = true
|
||
break
|
||
}
|
||
}
|
||
if driveDisconnected {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the backup contents labels — ONE PER TIER (R-537).
|
||
//
|
||
// This used to be a single string rendered on all three tier rows, computed from the APP's
|
||
// shape (`HasHDDData || HasVolumeData → "Adatok"`) rather than from what each tier actually
|
||
// captures. On a fresh one-drive box that made the Tier-1 row read „DB + Konfig + Adatok"
|
||
// over a unit holding a database dump, three volume tars and no copy of the customer's files
|
||
// at all — measured 2026-09-16 with five photos that were in no backup while the page said
|
||
// they were.
|
||
//
|
||
// The fact each tier captures is settled in 07-backup-architecture §6.1/§6.2 and is not
|
||
// changed here: a Tier-1 unit carries compose + app.yaml + DB dumps + volume tars and has no
|
||
// file-copy step; the drive-side file legs of a class-A app are carried by Tier 2 and Tier 3.
|
||
// So the label differs by tier, and one string cannot be true for all three.
|
||
hasDriveFileLegs := s.backupMgr != nil && s.backupMgr.HasDriveFileLegs(app.StackName)
|
||
base := []string{}
|
||
if hasDB {
|
||
base = append(base, "DB")
|
||
}
|
||
base = append(base, s.msgLang(lang, "backup.contents.db"))
|
||
withData := func(add bool) string {
|
||
p := append([]string{}, base...)
|
||
if add {
|
||
p = append(p, s.msgLang(lang, "backup.contents.data"))
|
||
}
|
||
return strings.Join(p, " + ")
|
||
}
|
||
// Tier 1: „Adatok" only when the app's data really is inside the volumes this unit captured.
|
||
// For an app that keeps its files on the drive it is a claim the unit cannot support.
|
||
tier1Contents := withData(app.HasVolumeData && !hasDriveFileLegs)
|
||
// Tier 2 / Tier 3 carry the file legs, so for them „Adatok" is true either way.
|
||
tier23Contents := withData(app.HasVolumeData || hasDriveFileLegs)
|
||
slug := ""
|
||
if s.stackMgr != nil {
|
||
if st, ok := s.stackMgr.GetStack(app.StackName); ok {
|
||
slug = st.Meta.Slug
|
||
}
|
||
}
|
||
|
||
row := AppBackupRow{
|
||
StackName: app.StackName,
|
||
DisplayName: app.DisplayName,
|
||
Slug: slug,
|
||
HasHDDData: app.HasHDDData,
|
||
HasDB: hasDB,
|
||
HasVolumeData: app.HasVolumeData,
|
||
DriveDisconnected: driveDisconnected,
|
||
StorageLabel: app.StorageLabel,
|
||
HDDSizeHuman: app.HDDSizeHuman,
|
||
Tier1Contents: tier1Contents,
|
||
Tier23Contents: tier23Contents,
|
||
Tier1DBStatus: tier1DBStatus,
|
||
}
|
||
|
||
// Tier 1: newest recovery-unit artifact time. ListRestorePoints does the correct
|
||
// per-drive namespace resolution (do NOT re-derive paths — the offbox DIAG trap).
|
||
// A known stack with no unit yet returns an empty list → no fabricated time.
|
||
if s.backupMgr != nil {
|
||
if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 {
|
||
row.Tier1LastRun = pts[0].Time
|
||
// R-259's sibling, R-258: THE VERDICT MUST BE ABOUT THIS APP, AND SILENT WHEN
|
||
// THERE IS NOTHING TO SAY.
|
||
//
|
||
// This used to read `status.LastDBDump.Success`, which is the box's single most
|
||
// recent dump RUN — whichever app it belonged to (backup.go: `m.lastDBDump`). So an
|
||
// app whose own dump failed last night showed a tick as long as some OTHER app
|
||
// dumped successfully afterwards, and an app with no database at all took the
|
||
// `nil` branch and went green on the mere existence of a restore point. A tick
|
||
// standing for "a file exists" is the presence-is-not-success rule as a UI badge.
|
||
//
|
||
// Per-app truth needs no new plumbing: DBDumpStatus.Results carries one DumpResult
|
||
// per database, each with its DiscoveredDB.StackName and its own Error.
|
||
//
|
||
// Three states, deliberately — the template renders an icon for "ok" and "error"
|
||
// and NOTHING for any third value, which is the slot "we do not know" belongs in.
|
||
// The recovery unit carries no per-run verdict of its own (recovery_unit.go: times
|
||
// and checksums, no outcome), so green cannot honestly be derived from presence.
|
||
row.Tier1LastStatus = appDumpVerdict(status.LastDBDump, app.StackName)
|
||
}
|
||
}
|
||
|
||
// Tier 3: off-box (NAS) inclusion state for this app.
|
||
row.OffboxEnabled = s.settings.IsAppOffbox(app.StackName)
|
||
row.Tier3State = tier3State(offboxConfigured, row.OffboxEnabled, offboxEscrowState)
|
||
|
||
// Status dot — app-data backup status
|
||
row.Status = "green"
|
||
row.StatusText = s.msgLang(lang, "backup.status.ok")
|
||
if hasDB && tier1DBStatus == "error" {
|
||
row.Status = "yellow"
|
||
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
|
||
// the customer's data may not be intact. Measured 2026-08-22: after a failed MariaDB replay
|
||
// the app reported `health=healthy, running=true, restarts=0` while its schema-version table
|
||
// held zero rows.
|
||
if held, why := s.backupMgr.RestoreHoldFor(app.StackName); held {
|
||
row.Status = "red"
|
||
row.StatusText = why
|
||
row.RestoreHeld = true
|
||
}
|
||
|
||
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
|
||
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
|
||
row.Tier2UserDisabled = cd.UserDisabled
|
||
if cd.UserDisabled {
|
||
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
|
||
} 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 = s.msgLang(lang, "backup.tier2.no_drive")
|
||
row.Tier2LastError = cd.LastError
|
||
} else if cd.Enabled {
|
||
row.Tier2Configured = true
|
||
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
|
||
row.Tier2SuccessTracked = cd.SuccessTracked
|
||
// Disclose, do not hide: an older good copy AND the fact that the newest attempt failed.
|
||
// Suppressing the failure to keep the surface calm is a quieter version of the same lie.
|
||
row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error"
|
||
if !cd.SuccessTracked {
|
||
s.noteTier2LegacyOnce(app.StackName)
|
||
}
|
||
row.Tier2LastError = cd.LastError
|
||
row.Tier2LastWarning = cd.LastWarning
|
||
row.Tier2SizeHuman = cd.LastSizeHuman
|
||
// R-102: ask the COPY, not the config. Tier2RestoreCoverage stats the recorded copy
|
||
// and reads its manifest, and it raises the same refusals the restore itself would —
|
||
// a disconnected destination, a pre-v2 layout — so an offer is never rendered for a
|
||
// copy the action would refuse. On any refusal the action is simply not offered; the
|
||
// row keeps rendering everything else it already showed.
|
||
//
|
||
// Slice 4: the computation lives in backup.Tier2UnitRestorePoint, because the guarded
|
||
// update asks the same question and a second copy of a predicate drifts (R-203).
|
||
if rp, rpErr := s.backupMgr.Tier2UnitRestorePoint(app.StackName); rpErr == nil {
|
||
row.Tier2UnitRestorable = rp.Restorable
|
||
// R-403: the UNIT action names the PACKAGE's date, not the run's. After a
|
||
// preserved leg those are different dates and the run's is the flattering one.
|
||
pkgDate, stale := rp.CopyDate, rp.PackagePreserved
|
||
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
|
||
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
|
||
if stale && pkgDate != "" {
|
||
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
|
||
}
|
||
}
|
||
switch cd.LastStatus {
|
||
case "ok":
|
||
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_ok")
|
||
case "error":
|
||
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_error")
|
||
case "running":
|
||
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_running")
|
||
default:
|
||
row.Tier2StatusBadge = "—"
|
||
}
|
||
}
|
||
}
|
||
|
||
// The one sentence about the app's own FILES. Set HERE, at the end of the loop, because it
|
||
// depends on the tier states resolved above — a note computed from the app's shape alone
|
||
// promised a copy that was paused (R-543). See driveFilesNoteFor.
|
||
row.DriveFilesNote, row.DriveFilesNoteLink, row.DriveFilesNoteLinkText =
|
||
driveFilesNoteFor(hasDriveFileLegs, row.Tier3State, row.Tier2Configured)
|
||
|
||
rows = append(rows, row)
|
||
}
|
||
// R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was
|
||
// kept is listed after the deployed ones, with the restore that brings it back.
|
||
if s.backupMgr != nil {
|
||
for _, u := range s.backupMgr.ListRemovedAppUnits() {
|
||
rows = append(rows, AppBackupRow{
|
||
StackName: u.StackName,
|
||
DisplayName: u.DisplayName,
|
||
Slug: u.StackName,
|
||
StorageLabel: u.DriveLabel,
|
||
Status: "yellow",
|
||
StatusText: s.msgLang(lang, "backup.removed_app"),
|
||
Removed: true,
|
||
RemovedUnitTime: u.Time,
|
||
Tier1LastRun: u.Time,
|
||
})
|
||
}
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// 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 (s *Server) tier2DestLabel(destPath, systemDataPath, lang string) string {
|
||
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
|
||
return s.msgLang(lang, "backup.tier2.dest_ssd")
|
||
}
|
||
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
|
||
}
|
||
|
||
// missingFileLegsRefusal builds the Hungarian sentence shown when a unit restore is refused because
|
||
// the unit carries no copy of the app's files (R-538). It names the route that CAN return them, and
|
||
// when there is none it says so rather than implying one exists.
|
||
//
|
||
// The three branches are the three real states, in the order a customer can act on them: the off-site
|
||
// copy (a full restore brings files AND database), the second drive (its file half is its own
|
||
// action), and nothing.
|
||
func (s *Server) missingFileLegsRefusal(ctx context.Context, stackName string) string {
|
||
const head = "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. "
|
||
if s.backupMgr != nil {
|
||
rows, _ := s.offsiteRestoreRows(ctx)
|
||
if row := resolveOffsiteRestoreApp(rows, stackName); row != nil {
|
||
return head + "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”."
|
||
}
|
||
if cov, err := s.backupMgr.Tier2RestoreCoverage(stackName); err == nil && cov.CanRestore() {
|
||
return head + "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”."
|
||
}
|
||
}
|
||
return head + "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót."
|
||
}
|
||
|
||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
stackName := r.FormValue("stack_name")
|
||
snapshotID := r.FormValue("snapshot_id")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||
}
|
||
|
||
if stackName == "" || snapshotID == "" {
|
||
http.Redirect(w, r, "/backups/restore?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||
return
|
||
}
|
||
// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
|
||
// restore work — never let it reach RestoreFromRecoveryUnit.
|
||
if !validStackName(stackName) {
|
||
s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||
http.Redirect(w, r, "/backups/restore?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
if s.backupMgr == nil {
|
||
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
|
||
// got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in
|
||
// a BACKGROUND goroutine (survives the request; the poll banner shows progress → result).
|
||
if msg, blocked := s.restoreOpBlocked(); blocked {
|
||
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||
return
|
||
}
|
||
// R-538: refuse a unit restore that would replay a database over files the unit does not hold —
|
||
// BEFORE the op begins, so nothing is stopped and the app's own wastebasket is left intact. The
|
||
// customer gets the route that CAN return their files instead of a success message over an app
|
||
// listing photos it cannot open.
|
||
//
|
||
// `accept_missing_files=1` is the explicit, separately-worded second step („csak az adatbázist és
|
||
// a beállításokat"). It is deliberately not a sibling of the main button: two controls whose
|
||
// difference is "your data comes back" are never siblings (R-48).
|
||
acceptMissingFiles := r.FormValue("accept_missing_files") == "1"
|
||
if !acceptMissingFiles {
|
||
if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 {
|
||
msg := s.missingFileLegsRefusal(r.Context(), stackName)
|
||
s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs))
|
||
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||
return
|
||
}
|
||
}
|
||
|
||
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||
s.backupMgr.BeginRestoreOp("restore", stackName)
|
||
go func() {
|
||
start := time.Now()
|
||
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
|
||
// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
|
||
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
|
||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.unit_failed", s.noteErr(err)))
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
|
||
stackName, time.Since(start), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
|
||
// R-353: this used to read `stackName+" visszaállítva ("+snapshotID+")."` — a sentence that is
|
||
// true of a run which returned an app's entire dataset AND of one that returned nothing at all.
|
||
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
|
||
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
|
||
// of it, which is the question the sentence exists to answer.
|
||
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
|
||
}()
|
||
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,
|
||
// so the wording is unit-testable — this string is the customer's only evidence that the operation did
|
||
// what its label promised.
|
||
//
|
||
// R-353. Modelled on reconstituteOutcomeMsg (the off-site twin), and it is the same defect arriving on
|
||
// the Tier-1 path: on 2026-08-21 an opengist restore reported „opengist visszaállítva (<snapshot>)."
|
||
// over a unit that held manifest.json and compose/ and nothing else. Every clause below is earned by
|
||
// having done the thing, so a restore that really returned data reads exactly as confidently as before.
|
||
//
|
||
// THE THREE CASES ARE THREE DIFFERENT FACTS, and collapsing any two is the whole bug:
|
||
//
|
||
// - something came back → name what, and how much.
|
||
// - nothing came back AND the unit listed nothing → the BACKUP held only settings. This is a claim
|
||
// about the backup, and it is the only claim the manifest can support.
|
||
// - nothing came back BUT the unit listed dumps → something is wrong. The customer's live data was
|
||
// never removed (the volume replay only ever writes), so say that, and stop them retrying blind.
|
||
//
|
||
// R-355 IS THE RULE THIS OBEYS AND THE REASON THE MIDDLE CASE IS WORDED AS IT IS. „ennek az
|
||
// alkalmazásnak nincs adata" is a claim ABOUT THE APP and must never be inferred from a counter. On the
|
||
// off-site path SafetyDump is the honest discriminator for the database question; this path has none,
|
||
// so no claim about the app is available here at all. 07-backup-architecture §6.3 is why that is not
|
||
// pedantry: R-361 destroyed apps' canonical .sql dumps for four months, so an absent dump has causes
|
||
// that have nothing to do with whether the app has a database.
|
||
//
|
||
// No filesystem path appears in the message, only counts — same rule as reconstituteOutcomeMsg.
|
||
func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
|
||
if res.VolumesReplayed > 0 || res.DBsReplayed > 0 {
|
||
var what string
|
||
if res.VolumesReplayed > 0 {
|
||
what = s.note("note.restore.volumes_count", res.VolumesReplayed)
|
||
}
|
||
if res.DBsReplayed > 0 {
|
||
if what != "" {
|
||
what += s.note("note.restore.and_database")
|
||
} else {
|
||
what = s.note("note.restore.the_database")
|
||
}
|
||
}
|
||
return s.note(unitRestoreDataKey, app, what)
|
||
}
|
||
// An unknown must never be drawn as a zero. The no-unit fallback cannot report counts, and the
|
||
// zero-value shape would otherwise read as „the backup held only settings" over a restore that may
|
||
// have replayed the app's whole dataset. Same failure direction as R-88: degrade to UNKNOWN.
|
||
if res.CountsUnknown {
|
||
return s.note(unitRestoreCountsUnknownKey, app)
|
||
}
|
||
if res.ManifestVolumes+res.ManifestDBs > 0 {
|
||
return s.note(unitRestoreNoneReturnedKey, app, res.ManifestVolumes, res.ManifestDBs)
|
||
}
|
||
return s.note(unitRestoreSettingsOnlyKey, app)
|
||
}
|
||
|
||
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
|
||
// r353_unit_outcome_test.go — a silent edit to any of them is how an honest message drifts back into a
|
||
// comforting one, which is the exact history of the sentence they replace.
|
||
const (
|
||
// unitRestoreDataMsgFmt — data really came back. %s app, %s the "N adatkötet[ és az adatbázis]"
|
||
// clause built above.
|
||
unitRestoreDataKey = "note.unit_restore_data"
|
||
|
||
// unitRestoreSettingsOnlyMsgFmt — nothing came back and the unit listed nothing. The FIGYELEM
|
||
// sentence is a statement about THE BACKUP; it deliberately says nothing about whether the app has
|
||
// data of its own, because the manifest cannot answer that (R-355).
|
||
unitRestoreSettingsOnlyKey = "note.unit_restore_settings_only"
|
||
|
||
// unitRestoreNoneReturnedMsgFmt — the unit listed data and none of it returned. %d volumes, %d
|
||
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
|
||
// replay only ever writes into volumes, so a replay that did nothing removed nothing, and a customer
|
||
// who believes otherwise will do something worse than waiting.
|
||
// unitRestoreCountsUnknownMsgFmt — the no-unit fallback. It claims only what is known: the restore
|
||
// ran and the app is back. It deliberately does NOT say data returned and does NOT say it did not.
|
||
unitRestoreCountsUnknownKey = "note.unit_restore_counts_unknown"
|
||
|
||
unitRestoreNoneReturnedKey = "note.unit_restore_none_returned"
|
||
)
|
||
|
||
// monitoringIntegritySchedule (R-359) describes what the off-site integrity job actually does.
|
||
//
|
||
// Until v0.227.0 this read „Hetente (vasarnap)" and described a check that DID NOT EXIST — the page
|
||
// told the operator a weekly integrity check ran while nothing in the product ever called restic's
|
||
// `check`. It now describes the job that was built, and the distinction is not pedantry: the job is
|
||
// due-ness based, so a box that was switched off on its check day is checked the next day it is on,
|
||
// and a page promising a fixed weekday would be a second untrue sentence replacing the first.
|
||
//
|
||
// ASCII deliberately, matching the surrounding rows — and §9's accented-grep rule: a zero result on
|
||
// accented text is suspect before the software is (R-364).
|
||
const monitoringIntegritySchedule = "Hetente, kimarado ellenorzest potol"
|
||
|
||
// C9-F1 customer-facing strings. Kept as named constants, not inlined, because both are asserted
|
||
// verbatim by tests — a silent edit to either is the way an honest message drifts back into a
|
||
// comforting one.
|
||
const (
|
||
// tier2NoCoverageMsg is shown when this app's data cannot come from the secondary copy AT ALL —
|
||
// no file legs AND no openable recovery unit in the copy. It NAMES the action that works rather
|
||
// than leaving a dead end: the keep-side recovery-unit restore on /backups/restore, which does
|
||
// restore named volumes and DB dumps (proven live, Campaign 9 A2). It also states plainly that no
|
||
// outage was taken, because the previous behaviour took one.
|
||
//
|
||
// R-103 NARROWED IT. Until v0.229.0 this same sentence was also shown to the far commoner case —
|
||
// no file legs but a full unit mirror sitting in the copy — and it sent those customers to a
|
||
// button on another page for data that is now restorable on the page they are already looking at.
|
||
// tier2UnitAvailableMsg is that case now.
|
||
tier2NoCoverageKey = "note.tier2_no_coverage"
|
||
|
||
// tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable
|
||
// recovery unit, so the answer is the action beside this one, not a different page. It names the
|
||
// button by its own label and says why the two differ, because the difference is the whole reason
|
||
// they are not one button: this one overwrites.
|
||
tier2UnitAvailableKey = "note.tier2_unit_available"
|
||
|
||
// tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never
|
||
// reads as a clean bill of health for data the operation never opened.
|
||
//
|
||
// R-103: it is NOT deleted now that the unit is restorable. It is appended where the FILE restore
|
||
// ran, and it is still exactly true of that restore — the file merge still never opens the
|
||
// database or the named volumes. Deleting it would let a clean file-restore result read as a clean
|
||
// bill of health for data the operation did not look at, which is the sentence it exists to
|
||
// prevent.
|
||
tier2UnitNotCoveredKey = "note.tier2_unit_not_covered"
|
||
|
||
// The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was
|
||
// just written over the app's live data — an action that overwrites must say what it overwrote
|
||
// with, in the sentence the customer is left holding.
|
||
tier2UnitRestoreSourceKey = "note.tier2_unit_restore_source"
|
||
|
||
// …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run
|
||
// too. Where no success has ever been recorded for this app, the date shown is evidence that a
|
||
// copy was attempted and nothing more, and the sentence must not present it as evidence of a copy.
|
||
tier2UnitRestoreSourceUnprovenKey = "note.tier2_unit_restore_source_unproven"
|
||
|
||
// R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template.
|
||
//
|
||
// It lives here for the same reason the R-353 outcome strings do: this sentence is the only thing
|
||
// standing between a customer and the loss of everything they made since the copy was taken, and a
|
||
// silent edit to it is how a warning drifts into a reassurance. Named constants can be asserted
|
||
// verbatim by a test; a sentence assembled inside an HTML attribute cannot, and R-364 makes
|
||
// grepping accented Hungarian out of rendered markup an unreliable check on top of that.
|
||
//
|
||
// The three parts are three separate obligations:
|
||
// Base — what this action DOES: it overwrites, including the database and internal volumes.
|
||
// Date — WHICH copy it overwrites with. Two forms, because a stamp that only records an
|
||
// ATTEMPT must not be presented as the date of a copy (R-101).
|
||
// Contrast — how it differs from the additive button beside it. The register's own requirement:
|
||
// a destructive operation reached from a non-destructive surface must carry the
|
||
// difference in the confirm, not rely on the customer inferring it from a label.
|
||
tier2UnitActionLabelKey = "note.tier2_unit_action_label"
|
||
|
||
tier2UnitConfirmBaseKey = "note.tier2_unit_confirm_base"
|
||
|
||
tier2UnitConfirmDateFmtKey = "note.tier2_unit_confirm_date_fmt"
|
||
|
||
tier2UnitConfirmDateUnprovenFmtKey = "note.tier2_unit_confirm_date_unproven_fmt"
|
||
|
||
tier2UnitConfirmContrastKey = "note.tier2_unit_confirm_contrast"
|
||
|
||
// tier2UnitStaleClause (R-403) — the package in this copy is OLDER than the copy's newest run,
|
||
// because that run PRESERVED it rather than replacing it with an empty one. Without this the
|
||
// confirm would name a date the customer reads as "last night" over a package from before it.
|
||
// The whole point of preserving the copy is lost if the surface then misdescribes what it kept.
|
||
tier2UnitStaleClauseKey = "note.tier2_unit_stale_clause"
|
||
|
||
// tier2UnitStaleNoticeFmt (R-403) — the same fact on the per-app backup card, where the customer
|
||
// looks BEFORE deciding anything. %s is the package's own date.
|
||
tier2UnitStaleNoticeFmtKey = "note.tier2_unit_stale_notice_fmt"
|
||
)
|
||
|
||
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
|
||
// the wording is unit-testable; the date is rendered by the SAME helper the rest of the surface uses.
|
||
//
|
||
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
|
||
// whole confirm because a date is missing would remove the warning and keep the destruction.
|
||
func (s *Server) tier2UnitConfirmMsg(copyDate string, proven bool) string {
|
||
return s.tier2UnitConfirmWithStaleness(copyDate, proven, false)
|
||
}
|
||
|
||
// tier2UnitConfirmWithStaleness is tier2UnitConfirmMsg for a copy whose PACKAGE may be older than its
|
||
// newest run (R-403). ONE implementation, two callers — the two-argument form above is the ordinary
|
||
// case where the run really did refresh the package.
|
||
func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
|
||
msg := s.note(tier2UnitConfirmBaseKey)
|
||
if copyDate != "" {
|
||
if proven {
|
||
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
|
||
} else {
|
||
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
|
||
}
|
||
}
|
||
msg += s.note(tier2UnitConfirmContrastKey)
|
||
// R-403 last, so it is the sentence the customer is left holding before they press.
|
||
if stale {
|
||
msg += s.note(tier2UnitStaleClauseKey)
|
||
}
|
||
return msg
|
||
}
|
||
|
||
// tier2UnitSourceMsg renders the "which copy" clause for the Tier-2 unit restore's outcome, or "" if
|
||
// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the
|
||
// date it puts in the confirm — so the sentence the customer approves and the sentence they are left
|
||
// with cannot name different copies.
|
||
func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||
// R-403: the OUTCOME names the same date the CONFIRM did — the PACKAGE's, not the copy's newest
|
||
// run. Live on demo-hp 2026-08-31 these disagreed by two and a half hours (confirm 11:43, outcome
|
||
// 14:23) after a preserved leg, and a customer reading both would not know which restore they had
|
||
// just had. §2.3's rule is "not a plain green success ANYWHERE", and the outcome is an anywhere.
|
||
date, _ := cov.UnitRestoreDate()
|
||
_, proven := cov.Tier2CopyDate()
|
||
if date == "" {
|
||
return ""
|
||
}
|
||
if proven {
|
||
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
|
||
}
|
||
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
|
||
}
|
||
|
||
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
|
||
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
|
||
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
stackName := r.FormValue("stack_name")
|
||
|
||
if stackName == "" {
|
||
http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||
return
|
||
}
|
||
// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
|
||
if !validStackName(stackName) {
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||
http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||
return
|
||
}
|
||
if s.backupMgr == nil {
|
||
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.
|
||
if msg, blocked := s.restoreOpBlocked(); blocked {
|
||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// C9-F1: refuse UP FRONT — before any op is begun and before the app is stopped — when this app's
|
||
// Tier-2 copy holds nothing this restore can read (43 of the 53 catalog apps: their data lives in
|
||
// Docker named volumes, captured into recovery-unit/ and never read here). Previously the customer
|
||
// got an outage, zero files, and „Nincs hiányzó fájl — minden fájl megvan a helyén." — a claim
|
||
// about data the restore never examined, at the exact moment they pressed it BECAUSE data was
|
||
// missing. Only the no-coverage case is pre-flighted; every other refusal keeps its existing async
|
||
// path so this change cannot alter behaviour anywhere else.
|
||
//
|
||
// R-103 SPLIT THE REFUSAL IN TWO. „no files to restore" has two different answers now, and giving
|
||
// both customers the same sentence is what sent one of them to another page for data that is
|
||
// restorable on this one.
|
||
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
|
||
if covErr == nil && !cov.CanRestore() {
|
||
msg := s.note(tier2NoCoverageKey)
|
||
if cov.CanRestoreUnit() {
|
||
msg = s.note(tier2UnitAvailableKey)
|
||
}
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped",
|
||
stackName, cov.HasUnit, cov.CanRestoreUnit())
|
||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
|
||
s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
|
||
go func() {
|
||
n, err := s.backupMgr.RestoreTier2Files(stackName)
|
||
if err != nil {
|
||
// The no-coverage refusal is not an operational failure — it means this action does not
|
||
// apply to this app. Say that, and name the one that does, instead of "sikertelen".
|
||
if errors.Is(err, backup.ErrTier2NoRestorableData) {
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore not applicable: stack=%s", stackName)
|
||
s.backupMgr.EndRestoreOp(false, s.note(tier2NoCoverageKey))
|
||
return
|
||
}
|
||
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
|
||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.file_failed", s.noteErr(err)))
|
||
return
|
||
}
|
||
// C9-F1 (the quiet half): even where the restore DOES cover something it covers only the
|
||
// file-based legs — never the app's database or named volumes, which sit unread in the same
|
||
// copy's recovery-unit/. „minden fájl megvan a helyén" was a blanket claim over data that was
|
||
// never opened; immich's 1.3 GB Postgres unit is the case that makes it dangerous. Claim only
|
||
// what was EXAMINED, and disclose the rest.
|
||
msg := s.note("note.restore.all_files_present")
|
||
if n > 0 {
|
||
msg = s.note("note.restore.files_from_second", stackName, n)
|
||
}
|
||
if cov.HasUnit {
|
||
msg += " " + s.note(tier2UnitNotCoveredKey)
|
||
}
|
||
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files, legs=%v)", stackName, n, cov.Legs)
|
||
s.backupMgr.EndRestoreOp(true, msg)
|
||
}()
|
||
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
|
||
// onto the SECOND DRIVE — the app's database dumps and named-volume tars, not just its loose files.
|
||
//
|
||
// It is the destructive twin of backupTier2RestoreHandler above and shares its shape deliberately:
|
||
// same guards, same single-writer refusal, same async goroutine, same status banner. What it does not
|
||
// share is its promise. The file restore only ever ADDS what is missing; this one OVERWRITES the
|
||
// app's live data with the copy's. The template's confirm carries that difference in words, and the
|
||
// two actions stay two buttons for exactly that reason (§8: they are different promises).
|
||
//
|
||
// The pre-flight refusal is the fail-closed gate: a `recovery-unit/` directory that exists is not a
|
||
// package. Refusing here means the app is never stopped for a mirror that could not have been read.
|
||
func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
stackName := r.FormValue("stack_name")
|
||
|
||
if stackName == "" {
|
||
http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||
return
|
||
}
|
||
// Same F2-defense as both restores beside it: a stack name is a single segment, never a path.
|
||
if !validStackName(stackName) {
|
||
s.logger.Printf("[WARN] [web] Tier-2 unit restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||
http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||
return
|
||
}
|
||
if s.backupMgr == nil {
|
||
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
|
||
// run. restoreOpBlocked() and not IsRunning(), because the concurrency flag is only taken inside
|
||
// the goroutine, after this handler has already returned.
|
||
if msg, blocked := s.restoreOpBlocked(); blocked {
|
||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Pre-flight, before any op is begun and before the app is stopped: does this copy hold a unit
|
||
// this restore can actually open? Only the no-unit case is pre-flighted; every other refusal keeps
|
||
// its existing async path, so this cannot alter behaviour anywhere else.
|
||
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
|
||
if covErr != nil {
|
||
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s: %v — app NOT stopped", stackName, covErr)
|
||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(covErr.Error()), http.StatusFound)
|
||
return
|
||
}
|
||
if !cov.CanRestoreUnit() {
|
||
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
|
||
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", tier2NoCoverageKey), http.StatusFound)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[WARN] [web] Tier-2 UNIT restore requested (async, OVERWRITES live data): stack=%s from %s", stackName, r.RemoteAddr)
|
||
s.backupMgr.BeginRestoreOp("tier2-unit-restore", stackName)
|
||
go func() {
|
||
start := time.Now()
|
||
res, err := s.backupMgr.RestoreTier2Unit(stackName)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
|
||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err)))
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
|
||
stackName, time.Since(start), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
|
||
// The outcome sentence is yesterday's — unitRestoreOutcomeMsg, unchanged, because what came
|
||
// back is the same fact whichever unit it came out of, and a second wording of it would be a
|
||
// second thing to keep honest. What IS added is which copy it came from and how old that copy
|
||
// is: this action overwrote the customer's live data, and the sentence they are left with has
|
||
// to say what it overwrote it with (Scenario E).
|
||
msg := s.unitRestoreOutcomeMsg(stackName, res)
|
||
if src := s.tier2UnitSourceMsg(cov); src != "" {
|
||
msg += " " + src
|
||
}
|
||
s.backupMgr.EndRestoreOp(true, msg)
|
||
}()
|
||
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
|
||
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
|
||
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
|
||
data := s.baseData(page, title)
|
||
data["CustomerID"] = s.cfg.Customer.ID
|
||
data["CustomerDomain"] = s.cfg.Customer.Domain
|
||
return data
|
||
}
|
||
|
||
// systemPageData builds the Rendszer subpage: read-only configuration, version/update,
|
||
// controller + server restart.
|
||
func (s *Server) systemPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings", "Beállítások")
|
||
data["TitleKey"] = "page.title.settings" // i18n: the Hungarian title above is what hu renders
|
||
data["GitRepoURL"] = s.cfg.Git.RepoURL
|
||
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
|
||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
|
||
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
|
||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||
|
||
// Self-update status
|
||
data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
|
||
if s.updater != nil {
|
||
// Registry mode (v0.112.0): credential-less is a SUPPORTED mode (anonymous pull of
|
||
// the public package), not an error state — the panel says which mode is active.
|
||
data["RegistryAnonymous"] = s.updater.RegistryAnonymous()
|
||
status := s.updater.GetStatus()
|
||
data["UpdateRunning"] = status.Running
|
||
if status.LastCheck != nil {
|
||
data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
|
||
data["LatestVersion"] = status.LastCheck.LatestVersion
|
||
data["LastCheckTime"] = status.LastCheck.CheckedAt
|
||
data["LastCheckError"] = status.LastCheck.Error
|
||
}
|
||
if status.LastState != nil {
|
||
data["LastUpdateState"] = status.LastState
|
||
}
|
||
data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
|
||
data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
|
||
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
|
||
// to. Empty = none set by the operator.
|
||
data["ControllerFloor"] = s.updater.GetFloor()
|
||
}
|
||
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
|
||
s.memoryCardData(data)
|
||
|
||
// „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the
|
||
// guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an
|
||
// address-less row („—") beats a wrong address. Hálózati név renders ONLY while Megosztás is
|
||
// enabled: the NetBIOS name exists only while samba runs — showing \\FELHOM otherwise would be
|
||
// a wrong promise.
|
||
data["NetLANAddress"] = s.sambaLANAddress()
|
||
data["NetGateway"] = s.guestGateway()
|
||
if smb := s.settings.GetSMBSettings(); smb.Enabled {
|
||
data["NetSMBName"] = smb.EffectiveServerName()
|
||
}
|
||
return data
|
||
}
|
||
|
||
// storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the
|
||
// data the unified agent-enriched drive view needs.
|
||
func (s *Server) storagePageData() map[string]interface{} {
|
||
data := s.settingsBaseData("storage", "Tárhely")
|
||
data["TitleKey"] = "page.title.storage" // i18n: the Hungarian title above is what hu renders
|
||
|
||
// Storage paths with display data
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
connectedCount := 0
|
||
for _, sp := range storagePaths {
|
||
if !sp.Disconnected && !sp.Decommissioned {
|
||
connectedCount++
|
||
}
|
||
}
|
||
var storageViews []StoragePathView
|
||
for _, sp := range storagePaths {
|
||
// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
|
||
// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
|
||
if sp.IsNetwork() {
|
||
continue
|
||
}
|
||
view := StoragePathView{
|
||
StoragePath: sp,
|
||
StoppedApps: sp.StoppedStacks,
|
||
HasOtherPaths: connectedCount > 1,
|
||
IsEnrolled: strings.HasPrefix(sp.Path, "/mnt/felhom-drives/"),
|
||
}
|
||
if sp.Disconnected {
|
||
// Skip I/O calls on disconnected drives — they'd hang or fail
|
||
view.IsMounted = false
|
||
} else if sp.Decommissioned {
|
||
view.IsMounted = false
|
||
view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
|
||
} else {
|
||
view.IsMounted = system.IsMountPoint(sp.Path)
|
||
view.AppDetails = s.appDetailsForPath(sp.Path)
|
||
view.FSInfo = system.GetFSInfo(sp.Path)
|
||
view.AppCount = len(view.AppDetails)
|
||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||
view.DiskInfo = di
|
||
}
|
||
// Detect USB for safe disconnect button
|
||
if view.FSInfo != nil && view.FSInfo.Device != "" {
|
||
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
|
||
}
|
||
}
|
||
storageViews = append(storageViews, view)
|
||
}
|
||
data["StoragePaths"] = storageViews
|
||
return data
|
||
}
|
||
|
||
// networkStoragePageData builds the Tárhely → Hálózati tárhely (NAS) subpage: the NAS shares
|
||
// with the agent's per-share health (ok/idle/unreachable/unknown). Split from the physical
|
||
// drive page — the two storage classes were confusingly interleaved on one page.
|
||
func (s *Server) networkStoragePageData() map[string]interface{} {
|
||
data := s.settingsBaseData("storage-network", "Hálózati tárhely")
|
||
data["TitleKey"] = "page.title.storage_network" // i18n: the Hungarian title above is what hu renders
|
||
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
|
||
// Capability banner: "no" swaps the add form for the agent-outdated notice (yes/unknown render
|
||
// the form — flaky states belong to the add-time gate). Short-budget probe, cache-backed.
|
||
data["NetAddSupport"] = s.netAddSupport()
|
||
return data
|
||
}
|
||
|
||
// notificationsPageData builds the Értesítések subpage: notification prefs + app-email.
|
||
func (s *Server) notificationsPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings-notifications", "Értesítések")
|
||
data["TitleKey"] = "page.title.settings_notifications" // i18n: the Hungarian title above is what hu renders
|
||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
||
|
||
// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
|
||
// path runs through the hub); the template hides the control otherwise.
|
||
appEmail := s.settings.GetAppEmail()
|
||
data["AppEmailEnabled"] = appEmail.Enabled
|
||
data["AppEmailFromName"] = appEmail.FromName
|
||
data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
|
||
return data
|
||
}
|
||
|
||
// securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction,
|
||
// emergency/recovery info.
|
||
func (s *Server) securityPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
|
||
data["TitleKey"] = "page.title.settings_security" // i18n: the Hungarian title above is what hu renders
|
||
|
||
// Recovery info for emergency section.
|
||
//
|
||
// ⚠ R-249 — THE VALUE DOES NOT GO IN THE PAGE, AND THAT IS THE WHOLE FIX.
|
||
//
|
||
// Until v0.207.0 this line put the retrieval passphrase into the template data and
|
||
// `settings_security.html` rendered it into a `display:none` span behind a „Megjelenít" button.
|
||
// That toggle stops the browser DRAWING it and nothing else: the plaintext was in the response
|
||
// body of every render, so a `curl` of the page returned it — which is how it was found, by
|
||
// landing in a session transcript during the 2026-08-07 walk. It was therefore also in browser
|
||
// caches, in history, in any saved page and in any screen-share of the page source.
|
||
//
|
||
// The product already had this exact rule and this page did not follow it — `escrow_handlers.go`
|
||
// states it for the recovery code: *"reveal (claim XHR only — R is NEVER templated server-side
|
||
// into HTML)"*. The passphrase now follows the same shape: the page carries only whether one
|
||
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
|
||
// explicit authenticated act and is logged as one.
|
||
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
|
||
data["HubURL"] = s.cfg.Hub.URL
|
||
data["SupportEmail"] = "support@felhom.eu"
|
||
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
||
|
||
// Geo-restriction data
|
||
data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
|
||
geo := s.settings.GetGeoRestriction()
|
||
if geo != nil {
|
||
data["GeoEnabled"] = geo.Enabled
|
||
data["GeoAllowedCountries"] = geo.AllowedCountries
|
||
data["GeoAppOverrides"] = geo.AppOverrides
|
||
data["GeoLastSync"] = geo.LastSync
|
||
data["GeoLastError"] = geo.LastSyncError
|
||
} else {
|
||
data["GeoEnabled"] = false
|
||
data["GeoAllowedCountries"] = []string{"HU"}
|
||
data["GeoAppOverrides"] = map[string]interface{}{}
|
||
}
|
||
// Deployed apps for per-app override selector
|
||
var deployedApps []map[string]string
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
|
||
continue
|
||
}
|
||
deployedApps = append(deployedApps, map[string]string{
|
||
"Name": stack.Name,
|
||
"Display": stack.Meta.DisplayName,
|
||
})
|
||
}
|
||
data["DeployedApps"] = deployedApps
|
||
return data
|
||
}
|
||
|
||
// settingsRetrievalPasswordRevealHandler — POST /settings/retrieval-password/reveal (v0.207.0, R-249).
|
||
//
|
||
// The ONLY path by which the retrieval passphrase reaches a browser. It is behind RequireAuth and
|
||
// CsrfProtect like every other POST on this mux, so reaching it takes a live session AND a token
|
||
// bound to it — where the old rendering took nothing but the ability to read a page the customer
|
||
// merely opened.
|
||
//
|
||
// WHY A POST FOR A READ, deliberately and not by accident: a GET would be re-fetchable from history,
|
||
// pre-fetchable by a browser, and loggable in any proxy's access log with the response cached. The
|
||
// act of revealing a secret is a state change in every sense that matters here, and CsrfProtect only
|
||
// covers unsafe methods — a GET would have no CSRF cover at all.
|
||
//
|
||
// `no-store` matters as much as the method: without it a back-navigation can re-present the response
|
||
// body from the disk cache, which is the same defect one layer down.
|
||
func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r *http.Request) {
|
||
pw := strings.TrimSpace(s.settings.GetRetrievalPassword())
|
||
if pw == "" {
|
||
// 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, 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
|
||
// credential writes `recovery_credential_revealed`, while reading this value off the page markup
|
||
// left no trace anywhere. The VALUE is never logged — only that it was asked for, and by whom.
|
||
s.logger.Printf("[INFO] [web] retrieval passphrase revealed via the security page from %s (value never logged)", clientIP(r))
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
|
||
}
|
||
|
||
// readInitialCreds is the ONE place an app's generated first-login credential is read — the live
|
||
// container read, behind a test seam. Both the info page (which takes only the non-secret half) and
|
||
// the reveal endpoint (which takes the value) go through here, so they cannot diverge.
|
||
func (s *Server) readInitialCreds(stackName string) (*stacks.ExtractedCreds, error) {
|
||
if s.initialCredsFn != nil {
|
||
return s.initialCredsFn(stackName)
|
||
}
|
||
return s.stackMgr.ReadInitialCredentials(stackName)
|
||
}
|
||
|
||
// appAutoFieldRevealHandler — POST /stacks/{name}/auto-field/reveal (v0.208.0, R-254 site two).
|
||
//
|
||
// WHAT §7.2 ESTABLISHED, AND WHY THIS EXISTS RATHER THAN A CHANGE TO THE HIDDEN INPUT.
|
||
//
|
||
// The hidden input (`{{if and (not $isDeployed) (eq .Type "secret")}}`) is NOT this defect. It fires
|
||
// only on the PRE-DEPLOY form, and README §318 documents why the value must round-trip: the customer
|
||
// is shown the generated secrets so they can write them down, and submitting them back is what makes
|
||
// the saved value the SAME one they saw ("no silent re-generation on submit"). A form must carry what
|
||
// it submits.
|
||
//
|
||
// The defect is the neighbouring READONLY display input. On an ALREADY-DEPLOYED app the hidden input
|
||
// is correctly omitted — nothing is being submitted — yet `<input type="password" … value="{{$val}}"
|
||
// readonly>` still renders the secret into the body of a page the customer merely opens. That is
|
||
// R-249's shape exactly, with no form to justify it.
|
||
//
|
||
// PER-SECRET, NOT GENERIC: it serves only fields the CATALOG declares `type: secret` on that stack.
|
||
// An env var that is not an auto-generated secret field is refused — that check is the authorisation,
|
||
// 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, s.msg(r, "escrow.stack_mgr_unavailable"))
|
||
return
|
||
}
|
||
stack, ok := s.stackMgr.GetStack(stackName)
|
||
if !ok {
|
||
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, s.msg(r, "escrow.missing_field"))
|
||
return
|
||
}
|
||
// AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata.
|
||
allowed := false
|
||
for _, f := range stack.Meta.AutoGeneratedFields() {
|
||
if f.EnvVar == envVar && f.Type == "secret" {
|
||
allowed = true
|
||
break
|
||
}
|
||
}
|
||
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, s.msg(r, "escrow.field_not_revealable"))
|
||
return
|
||
}
|
||
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
||
if appCfg == nil {
|
||
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, 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))
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
escrowJSON(w, http.StatusOK, map[string]any{"value": val}, "")
|
||
}
|
||
|
||
// appInitialCredsRevealHandler — POST /apps/{slug}/initial-credentials/reveal (v0.208.0, R-254).
|
||
//
|
||
// The ONLY path by which an app's generated first-login password reaches a browser. Same shape as
|
||
// v0.207.0's retrieval-password reveal, deliberately: POST (so CsrfProtect covers it and it is not
|
||
// re-fetchable from history), `no-store`, and **logged as an act** — reading it off the markup left
|
||
// no trace anywhere, which is why nobody can say whether any of these was ever read.
|
||
//
|
||
// ⚠ PER-SECRET, NOT GENERIC. This serves exactly one kind of value for one app. A single endpoint
|
||
// that returned any named secret would be a worse thing than the defect it fixed: it would turn three
|
||
// narrow exposures into one lever with a parameter.
|
||
//
|
||
// §7.1 — IT RE-READS THE CONTAINER, it does not serve a copy the page already had. Caching the value
|
||
// in the handler's page data would put it back in the response body one layer in, which is the defect.
|
||
// The consequence is that the reveal can legitimately fail (container stopped, file deleted after
|
||
// first login) and it SAYS SO — an empty string here would render as a blank password and read as
|
||
// "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, s.msg(r, "escrow.stack_mgr_unavailable"))
|
||
return
|
||
}
|
||
// Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
|
||
// of "which app is this slug" is how a reveal ends up answering for a different app than the page
|
||
// the customer is looking at.
|
||
var found *stacks.Stack
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if stack.Meta.Slug == slug {
|
||
found = &stack
|
||
break
|
||
}
|
||
}
|
||
if found == nil {
|
||
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.
|
||
if found.Name == fileBrowserStack {
|
||
s.fileBrowserPasswordReveal(w, r)
|
||
return
|
||
}
|
||
creds, err := s.readInitialCreds(found.Name)
|
||
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, 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, 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))
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
escrowJSON(w, http.StatusOK, map[string]any{"password": creds.Password}, "")
|
||
}
|
||
|
||
// fileBrowserStack is the protected infra stack whose admin password R-513 manages.
|
||
const fileBrowserStack = "filebrowser"
|
||
|
||
// fileBrowserPasswordReveal serves the generated FileBrowser admin password (R-513) under the same
|
||
// rules as every initial-credential reveal: POST + CSRF (router), no-store, logged as an act, and a
|
||
// refusal that says why when there is nothing to show.
|
||
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, s.msg(r, "escrow.settings_unavailable"))
|
||
return
|
||
}
|
||
state, enc, _ := s.settings.GetFileBrowserAdmin()
|
||
if state != settings.FileBrowserAdminGenerated || enc == "" {
|
||
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, s.msg(r, "escrow.password_unreadable"))
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] filebrowser admin password revealed from %s (value never logged)", clientIP(r))
|
||
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
|
||
}
|
||
|
||
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_system", s.systemPageData())
|
||
}
|
||
|
||
// storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here.
|
||
func (s *Server) storagePageHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.storagePageData()
|
||
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
||
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
||
}
|
||
s.executeTemplate(w, r, "storage", data)
|
||
}
|
||
|
||
// storageNetworkPageHandler serves the Tárhely → Hálózati tárhely (NAS) subpage.
|
||
func (s *Server) storageNetworkPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "storage_network", s.networkStoragePageData())
|
||
}
|
||
|
||
// settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same
|
||
// path is the save handler — dispatch is split in the router).
|
||
func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_notifications", s.notificationsPageData())
|
||
}
|
||
|
||
// settingsSecurityPageHandler serves GET /settings/security.
|
||
func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_security", s.securityPageData())
|
||
}
|
||
|
||
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
currentPassword := r.FormValue("current_password")
|
||
newPassword := r.FormValue("new_password")
|
||
confirmPassword := r.FormValue("confirm_password")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
|
||
}
|
||
|
||
data := s.securityPageData()
|
||
|
||
// Validate current password
|
||
effectiveHash := s.effectivePasswordHash()
|
||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
|
||
}
|
||
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"] = s.msg(r, "settings.pw_too_short")
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Validate passwords match
|
||
if newPassword != confirmPassword {
|
||
data["PasswordError"] = s.msg(r, "settings.pw_mismatch")
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Generate bcrypt hash
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
|
||
data["PasswordError"] = s.msg(r, "settings.pw_save_error")
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// 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"] = s.msg(r, "settings.pw_save_error")
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
|
||
|
||
// Invalidate all sessions (force re-login)
|
||
s.invalidateAllSessions()
|
||
|
||
// Redirect to login with flash message
|
||
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.
|
||
// Used ONLY to decide whether a save is a no-op; never to decide what to store.
|
||
func sameEventSet(a, b []string) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
sa, sb := make(map[string]bool, len(a)), make(map[string]bool, len(b))
|
||
for _, e := range a {
|
||
sa[e] = true
|
||
}
|
||
for _, e := range b {
|
||
sb[e] = true
|
||
}
|
||
if len(sa) != len(sb) {
|
||
return false
|
||
}
|
||
for e := range sa {
|
||
if !sb[e] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// dedupeEvents removes duplicates while PRESERVING ORDER.
|
||
//
|
||
// Order is not cosmetic here: the stored list is compared byte-for-byte by
|
||
// TestR329Part4_RoundTripIsByteIdentical, which exists because a settings page that quietly reorders
|
||
// or drops a key while merely RENDERING it would be a worse fault than the compound toggles it
|
||
// replaced. It is needed because the legacy compound form name and its two replacements can both be
|
||
// present in one POST — a browser still showing the old page, or a client that sends both.
|
||
func dedupeEvents(in []string) []string {
|
||
seen := make(map[string]bool, len(in))
|
||
out := in[:0:0]
|
||
for _, e := range in {
|
||
if seen[e] {
|
||
continue
|
||
}
|
||
seen[e] = true
|
||
out = append(out, e)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
|
||
}
|
||
|
||
email := strings.TrimSpace(r.FormValue("notification_email"))
|
||
cooldownStr := r.FormValue("cooldown_hours")
|
||
cooldownHours := 6
|
||
if cooldownStr != "" {
|
||
if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
|
||
cooldownHours = 6
|
||
}
|
||
}
|
||
if cooldownHours < 1 {
|
||
cooldownHours = 1
|
||
}
|
||
if cooldownHours > 168 {
|
||
cooldownHours = 168
|
||
}
|
||
|
||
// Collect enabled events from checkboxes
|
||
var enabledEvents []string
|
||
// Single-event checkboxes
|
||
for _, evt := range []string{
|
||
"backup_failed", "db_dump_failed", "backup_integrity_failed",
|
||
"crossdrive_failed", "offbox_enlarge_blocked", "storage_disconnected",
|
||
"node_down", "health_critical",
|
||
// R-329: app_start_failed is customer-switchable but DEFAULT OFF — it is deliberately absent
|
||
// from settings.DefaultEnabledEvents (operator ruling, 2026-08-23). The OPERATOR is emailed
|
||
// regardless: processOperator consults operatorOn, the address and a cooldown, and never the
|
||
// customer's preferences. This toggle governs the customer leg only.
|
||
"app_start_failed",
|
||
"storage_reconnected", "health_recovered",
|
||
} {
|
||
if r.FormValue("event_"+evt) == "on" {
|
||
enabledEvents = append(enabledEvents, evt)
|
||
}
|
||
}
|
||
// R-329 Part 4 — WAS: two compound toggles, one checkbox writing TWO event types each.
|
||
//
|
||
// `event_disk_alerts` labelled "Lemez figyelmeztetés (90%+)" also governed `disk_critical` — the
|
||
// drive-is-FAILING alarm. A customer switching off a disk-nearly-full notice silently switched off
|
||
// "this drive is dying", and the label claimed only the first. Those are not the same decision.
|
||
// `event_expected_missed` had the same shape over the backup and database-dump misses.
|
||
//
|
||
// Each is now its own labelled toggle. **The compound form names are still READ**, so a browser
|
||
// still on the old page, or a bookmarked POST, keeps working and cannot silently drop a key — the
|
||
// migration risk here is a settings page that changes a setting while rendering it, which would be
|
||
// worse than the defect. Pinned by TestR329Part4_RoundTripIsByteIdentical.
|
||
for _, c := range []struct {
|
||
form string
|
||
events []string
|
||
}{
|
||
{"event_disk_alerts", []string{"disk_warning", "disk_critical"}}, // legacy compound
|
||
{"event_disk_warning", []string{"disk_warning"}},
|
||
{"event_disk_critical", []string{"disk_critical"}},
|
||
{"event_expected_missed", []string{"expected_backup_missed", "expected_dbdump_missed"}}, // legacy compound
|
||
{"event_expected_backup_missed", []string{"expected_backup_missed"}},
|
||
{"event_expected_dbdump_missed", []string{"expected_dbdump_missed"}},
|
||
} {
|
||
if r.FormValue(c.form) == "on" {
|
||
enabledEvents = append(enabledEvents, c.events...)
|
||
}
|
||
}
|
||
enabledEvents = dedupeEvents(enabledEvents)
|
||
|
||
// R-329 Part 4 — A SAVE THAT CHANGES NOTHING MUST STORE NOTHING NEW.
|
||
//
|
||
// Splitting the two compound toggles rewrote which form names produce which event keys, so a
|
||
// customer who merely OPENS this page and presses Save now travels a different code path than the
|
||
// one that wrote their stored list. If that path emits the same SET in a different ORDER, their
|
||
// stored bytes change for no reason a person asked for — and a settings page that quietly rewrites
|
||
// a setting while rendering it is a worse fault than the compound labels being fixed.
|
||
//
|
||
// So: if the submitted set is identical to what is already stored, keep the STORED slice verbatim.
|
||
// This is byte-identity by construction rather than by argument, which is the only kind worth
|
||
// having here. Pinned by TestR329Part4_RoundTripIsByteIdentical over both starting shapes.
|
||
if cur := s.settings.GetNotificationPrefs(); cur != nil &&
|
||
sameEventSet(cur.EnabledEvents, enabledEvents) {
|
||
enabledEvents = cur.EnabledEvents
|
||
}
|
||
|
||
// EMPTY-EMAIL WIPE GUARD (2026-07-15 demo incident): a blank email box saved while events are
|
||
// still enabled would store an empty Email AND push it to the hub via SyncPreferences, wiping
|
||
// the customer's provisioning-seeded alert address — enabled events with nowhere to send them.
|
||
// The only way to reach this state is the bug, so refuse the save outright (BEFORE
|
||
// SetNotificationPrefs and BEFORE any hub sync), leaving the stored email untouched, and ask for
|
||
// an address. The intentional "turn everything off" case (empty email + ZERO events) falls
|
||
// through below — clearing the email is legitimate there and the empty hub push is correct.
|
||
if email == "" && len(enabledEvents) > 0 {
|
||
s.logger.Printf("[WARN] [web] Refused notification save: empty email with %d enabled event(s) — would wipe hub-side alert delivery", len(enabledEvents))
|
||
data := s.notificationsPageData()
|
||
// Repaint the customer's just-submitted intent (their ticked events + chosen cooldown, empty
|
||
// email) so they only need to add an address, not re-tick everything. Overlay the stored
|
||
// prefs — do NOT persist this; it is render-only.
|
||
data["NotificationPrefs"] = &settings.NotificationPrefs{
|
||
Email: email,
|
||
EnabledEvents: enabledEvents,
|
||
CooldownHours: cooldownHours,
|
||
}
|
||
data["NotificationError"] = s.msg(r, "settings.notify_email_required")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
prefs := &settings.NotificationPrefs{
|
||
Email: email,
|
||
EnabledEvents: enabledEvents,
|
||
CooldownHours: cooldownHours,
|
||
}
|
||
|
||
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"] = s.msg(r, "settings.notify_save_error")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
||
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
|
||
|
||
// Sync preferences to hub
|
||
data := s.notificationsPageData()
|
||
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"] = s.msg(r, "settings.notify_saved_sync_failed", err)
|
||
} else {
|
||
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
|
||
}
|
||
} else {
|
||
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
|
||
}
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
|
||
// SMTP shim to match (no controller restart needed).
|
||
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
|
||
fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))
|
||
|
||
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"] = s.msg(r, "settings.app_email_save_error")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
// v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the
|
||
// setting) — report out-of-cycle so the hub sees it in seconds.
|
||
s.reportTriggerNow()
|
||
// Reconcile the shim's running state with the new toggle.
|
||
if s.mailShim != nil {
|
||
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"] = s.msg(r, "settings.app_email_shim_failed")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
}
|
||
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"] = s.msg(r, "settings.app_email_on")
|
||
} else {
|
||
data["AppEmailSuccess"] = s.msg(r, "settings.app_email_off")
|
||
}
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.notificationsPageData()
|
||
|
||
if s.notifier == nil {
|
||
data["NotificationError"] = s.msg(r, "settings.notify_disabled")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
err := s.notifier.SendTest()
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
|
||
data["NotificationError"] = s.msg(r, "settings.test_email_failed", err)
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
data["NotificationSuccess"] = s.msg(r, "settings.test_email_sent")
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
// --- Storage path management handlers ---
|
||
|
||
func (s *Server) countAppsUsingPath(storagePath string) int {
|
||
count := 0
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if appCfg.Env["HDD_PATH"] == storagePath {
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func (s *Server) appsUsingPath(storagePath string) []string {
|
||
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
||
}
|
||
|
||
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
|
||
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
|
||
// with no HDD_PATH (SSD-resident) is never "missing".
|
||
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
||
if hddPath == "" {
|
||
return "", false
|
||
}
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == hddPath {
|
||
// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
|
||
// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
|
||
// cascade. An `unreachable` NAS must not look like a removed drive.
|
||
if sp.IsNetwork() {
|
||
return "", false
|
||
}
|
||
if sp.Decommissioned || sp.Disconnected {
|
||
return s.settings.GetStorageLabel(hddPath), true
|
||
}
|
||
return "", false // present + available
|
||
}
|
||
}
|
||
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
||
}
|
||
|
||
// networkStorageWarnings returns two stack-name → share-label maps for deployed apps on NAS
|
||
// network paths:
|
||
// - warnings: the agent reports the share `unreachable` — RECOVERABLE ("hálózati tárhely nem
|
||
// elérhető"), explicitly NOT the drive missing/stop-cascade; clears when the NAS returns.
|
||
// - stubs: the path is a plain local STUB in the controller's namespace (RCA fix 2 — the
|
||
// guest-reboot state where apps silently see an empty dir while the agent's host-side view is
|
||
// healthy). Checked from THIS process (the consuming namespace), independent of the agent.
|
||
//
|
||
// Stub wins: a stack never appears in both. An idle autofs trigger is HEALTHY and is never
|
||
// force-mounted from here (classification reads the fs magic only). Best-effort: an agent error
|
||
// drops the unreachable leg but the stub leg still runs.
|
||
func (s *Server) networkStorageWarnings(list []stacks.Stack) (warnings, stubs map[string]string) {
|
||
warnings, stubs = map[string]string{}, map[string]string{}
|
||
if s.settings == nil || s.stackMgr == nil {
|
||
return warnings, stubs
|
||
}
|
||
netPaths := map[string]settings.StoragePath{}
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.IsNetwork() {
|
||
netPaths[sp.Path] = sp
|
||
}
|
||
}
|
||
if len(netPaths) == 0 {
|
||
return warnings, stubs
|
||
}
|
||
// Stub leg — the consuming namespace's own verdict (no agent, no force-mount).
|
||
stubPaths := s.stubNetworkPaths(netPaths)
|
||
// Unreachable leg — the agent's host-side liveness view (unchanged behavior).
|
||
unreachable := map[string]string{} // path → label
|
||
if agent, err := s.agentClient(); err == nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if mounts, err := agent.ListNetStorage(ctx); err == nil {
|
||
for _, m := range mounts {
|
||
if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
|
||
continue
|
||
}
|
||
p := settings.NetworkMountRoot + "/" + m.Name
|
||
if sp, ok := netPaths[p]; ok {
|
||
lbl := sp.Label
|
||
if lbl == "" {
|
||
lbl = m.Name
|
||
}
|
||
unreachable[p] = lbl
|
||
}
|
||
}
|
||
} else {
|
||
s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
|
||
}
|
||
}
|
||
return networkStorageWarningsIn(list, s.stackMgr.LoadAppConfigByName, unreachable, stubPaths)
|
||
}
|
||
|
||
// stubNetworkPaths classifies each registered network path in the controller's namespace and
|
||
// returns path→label for every STUB (RCA fix 2). Idle autofs is healthy and classification never
|
||
// force-mounts (fs magic read only); unknown (timeout/statfs error) is NOT a stub — fail open.
|
||
func (s *Server) stubNetworkPaths(netPaths map[string]settings.StoragePath) map[string]string {
|
||
out := map[string]string{}
|
||
for p, sp := range netPaths {
|
||
if s.classifyFSPath(p) == system.FSClassStub {
|
||
lbl := sp.Label
|
||
if lbl == "" {
|
||
lbl = strings.TrimPrefix(p, settings.NetworkMountRoot+"/")
|
||
}
|
||
out[p] = lbl
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// networkStorageWarningsIn is the pure per-stack mapping core (the appsUsingPathIn pattern): given
|
||
// the path→label verdict sets, assign each deployed stack its badge. Stub WINS over unreachable —
|
||
// a stack never appears in both maps.
|
||
func networkStorageWarningsIn(list []stacks.Stack, load func(string) *stacks.AppConfig, unreachable, stubPaths map[string]string) (warnings, stubs map[string]string) {
|
||
warnings, stubs = map[string]string{}, map[string]string{}
|
||
if len(unreachable) == 0 && len(stubPaths) == 0 {
|
||
return warnings, stubs
|
||
}
|
||
for _, st := range list {
|
||
if !st.Deployed {
|
||
continue
|
||
}
|
||
if cfg := load(st.Name); cfg != nil {
|
||
hdd := cfg.Env["HDD_PATH"]
|
||
if lbl, bad := stubPaths[hdd]; bad {
|
||
stubs[st.Name] = lbl // stub wins over unreachable
|
||
continue
|
||
}
|
||
if lbl, bad := unreachable[hdd]; bad {
|
||
warnings[st.Name] = lbl
|
||
}
|
||
}
|
||
}
|
||
return warnings, stubs
|
||
}
|
||
|
||
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
||
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
||
out := map[string]string{}
|
||
for _, st := range list {
|
||
if !st.Deployed {
|
||
continue
|
||
}
|
||
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
||
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
|
||
out[st.Name] = label
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
||
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
||
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
||
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
|
||
var names []string
|
||
for _, stack := range allStacks {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := loadCfg(stack.Name); appCfg != nil {
|
||
if appCfg.Env["HDD_PATH"] == storagePath {
|
||
names = append(names, stack.Meta.DisplayName)
|
||
}
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
||
var details []StorageAppDetail
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
|
||
if appCfg == nil {
|
||
continue
|
||
}
|
||
hddPath := appCfg.Env["HDD_PATH"]
|
||
if hddPath != storagePath {
|
||
continue
|
||
}
|
||
detail := StorageAppDetail{
|
||
Name: stack.Meta.DisplayName,
|
||
Stack: stack.Meta.Slug,
|
||
}
|
||
// Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is
|
||
// NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s).
|
||
// Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A).
|
||
var total int64
|
||
var any bool
|
||
for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) {
|
||
d := backup.AppDataDir(storagePath, name)
|
||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||
total += dirSizeBytesWalk(d)
|
||
any = true
|
||
}
|
||
}
|
||
if any {
|
||
detail.SizeHuman = humanizeDirBytes(total)
|
||
}
|
||
details = append(details, detail)
|
||
}
|
||
return details
|
||
}
|
||
|
||
// dirSizeBytesWalk returns the total size in bytes of the regular files under path (0 if absent).
|
||
func dirSizeBytesWalk(path string) int64 {
|
||
var total int64
|
||
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() {
|
||
return nil
|
||
}
|
||
total += info.Size()
|
||
return nil
|
||
})
|
||
return total
|
||
}
|
||
|
||
// humanizeDirBytes formats a byte count as B/KB/MB/GB (the storage-page display convention).
|
||
func humanizeDirBytes(total int64) string {
|
||
const (
|
||
KB = 1024
|
||
MB = KB * 1024
|
||
GB = MB * 1024
|
||
)
|
||
switch {
|
||
case total >= GB:
|
||
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
|
||
case total >= MB:
|
||
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
|
||
case total >= KB:
|
||
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
|
||
default:
|
||
return fmt.Sprintf("%d B", total)
|
||
}
|
||
}
|
||
|
||
// dirSizeHuman returns a human-readable size for a single directory.
|
||
func dirSizeHuman(path string) string {
|
||
return humanizeDirBytes(dirSizeBytesWalk(path))
|
||
}
|
||
|
||
func formatFreeSpace(gb float64) string {
|
||
if gb >= 1000 {
|
||
return fmt.Sprintf("%.1f TB", gb/1024)
|
||
}
|
||
return fmt.Sprintf("%.1f GB", gb)
|
||
}
|
||
|
||
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
path := filepath.Clean(r.FormValue("storage_path"))
|
||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||
isDefault := r.FormValue("storage_default") == "true"
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
|
||
}
|
||
|
||
if label == "" {
|
||
label = settings.InferStorageLabel(path)
|
||
}
|
||
|
||
data := s.storagePageData()
|
||
|
||
// 1. Exists and is directory
|
||
fi, err := os.Stat(path)
|
||
if err != nil || !fi.IsDir() {
|
||
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"] = s.msg(r, "storage.err_not_separate")
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// 3. Writable
|
||
if !system.IsWritable(path) {
|
||
data["StorageError"] = s.msg(r, "storage.err_not_writable")
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// 4. No overlap with existing paths
|
||
for _, existing := range s.settings.GetStoragePaths() {
|
||
if system.PathsOverlap(path, existing.Path) {
|
||
data["StorageError"] = s.msg(r, "storage.err_overlaps", existing.Path)
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 5. Soft warning if not under /mnt/
|
||
if !strings.HasPrefix(path, "/mnt/") {
|
||
s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path)
|
||
}
|
||
|
||
sp := settings.StoragePath{
|
||
Path: path,
|
||
Label: label,
|
||
IsDefault: isDefault,
|
||
Schedulable: true,
|
||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
|
||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
|
||
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(s.msg(r, "storage.msg_added", path)), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
|
||
}
|
||
|
||
data := s.storagePageData()
|
||
|
||
// Check: apps using this path
|
||
apps := s.appsUsingPath(path)
|
||
if len(apps) > 0 {
|
||
data["StorageError"] = s.msg(r, "storage.err_in_use", strings.Join(apps, ", "))
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// Check: cannot remove default
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == path && sp.IsDefault {
|
||
data["StorageError"] = s.msg(r, "storage.err_default")
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Check: last path
|
||
if len(s.settings.GetStoragePaths()) <= 1 {
|
||
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"] = s.msg(r, "storage.err_delete")
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
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(s.msg(r, "storage.msg_removed", path)), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
|
||
}
|
||
|
||
if err := s.settings.SetDefaultStoragePath(path); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
|
||
http.Redirect(w, r, "/storage", http.StatusFound)
|
||
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(s.msg(r, "storage.msg_default_set", path)), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
schedulable := r.FormValue("schedulable") == "true"
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
|
||
}
|
||
|
||
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
|
||
http.Redirect(w, r, "/storage", http.StatusFound)
|
||
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(s.msg(r, "storage.msg_state_changed", path)), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
|
||
}
|
||
|
||
if label == "" || len(label) > 50 {
|
||
data := s.storagePageData()
|
||
data["StorageError"] = s.msg(r, "storage.err_label")
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
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"] = 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(s.msg(r, "storage.msg_label_changed", label)), http.StatusFound)
|
||
}
|
||
|
||
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
||
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
||
func (s *Server) SyncFileBrowserMounts() {
|
||
s.syncFileBrowserMounts(false)
|
||
}
|
||
|
||
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
|
||
// database when sources change. Use only after restore — normal operations should use
|
||
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
|
||
func (s *Server) SyncFileBrowserMountsReset() {
|
||
s.syncFileBrowserMounts(true)
|
||
}
|
||
|
||
// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
|
||
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
|
||
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
|
||
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
|
||
// skipped. Pure + isMount-injected for testability.
|
||
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
|
||
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
|
||
}
|
||
|
||
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
|
||
s.fileBrowserMu.Lock()
|
||
defer s.fileBrowserMu.Unlock()
|
||
|
||
stackDir := "/opt/docker/stacks/filebrowser"
|
||
composePath := stackDir + "/docker-compose.yml"
|
||
|
||
// Check if FileBrowser stack exists
|
||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
||
s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath)
|
||
return
|
||
}
|
||
|
||
// Get all active storage paths
|
||
paths := s.settings.GetStoragePaths()
|
||
|
||
// Use domain from controller config
|
||
domain := s.cfg.Customer.Domain
|
||
if domain == "" {
|
||
s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured")
|
||
return
|
||
}
|
||
|
||
// Build volume mount lines + the config source set (R-67: the two must agree — a source with
|
||
// no mount behind it renders a broken sidebar entry).
|
||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||
isMount: system.IsMountPoint,
|
||
classify: s.classifyFSPath,
|
||
ensureSkeleton: s.ensureUserdataSkeleton,
|
||
logger: s.logger,
|
||
// R-203. LATENT at this site rather than live: the comment below records that the system drive
|
||
// is deliberately never a registered StoragePath, so every `sp.Path` here is an enrolled drive
|
||
// and the resolver is the identity today. Wired anyway — the contract is uniform, and the next
|
||
// person to register a non-enrolled path should not have to rediscover this.
|
||
nsRootFor: func(p string) string { return appbackup.NamespaceRootFor(p, s.cfg.Paths.SystemDataPath) },
|
||
})
|
||
|
||
// R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above.
|
||
// The system drive is deliberately not a registered StoragePath (it would become a customer-visible
|
||
// drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root
|
||
// first — a source whose path does not exist renders a broken sidebar entry.
|
||
importSource := false
|
||
if s.stackMgr != nil {
|
||
if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" {
|
||
if err := s.stackMgr.EnsureImportRoot(); err != nil {
|
||
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err)
|
||
}
|
||
storageMounts = append(storageMounts,
|
||
fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount))
|
||
importSource = true
|
||
}
|
||
}
|
||
|
||
// Generate and write config.yaml (sources + sidebar entries per drive/share)
|
||
configPath := stackDir + "/config.yaml"
|
||
fbConfig := generateFileBrowserConfig(configPaths, importSource)
|
||
|
||
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
||
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
||
// after we write it, so the recreate decision is made AFTER the writes against the final files.
|
||
oldConfig, _ := os.ReadFile(configPath)
|
||
oldCompose, _ := os.ReadFile(composePath)
|
||
|
||
// Detect if sources changed — if so, the database must be reset so
|
||
// FileBrowser picks up the new source list (user prefs cache old sources).
|
||
sourcesChanged := string(oldConfig) != fbConfig
|
||
|
||
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
|
||
return
|
||
}
|
||
|
||
// Re-apply active integrations into config.yaml (before container restart)
|
||
if im := s.integrationMgr.Load(); im != nil {
|
||
im.ReapplyConfigForTarget("filebrowser")
|
||
}
|
||
|
||
// Generate and write compose (includes config.yaml mount)
|
||
compose := generateFileBrowserCompose(domain, storageMounts)
|
||
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
|
||
return
|
||
}
|
||
|
||
// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
|
||
// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
|
||
// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
|
||
finalConfig, _ := os.ReadFile(configPath)
|
||
finalCompose, _ := os.ReadFile(composePath)
|
||
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
||
|
||
// If sources changed and caller requested a DB reset (restore flow),
|
||
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
||
// Normal operations skip this to preserve user accounts, permissions, and share links.
|
||
if sourcesChanged && resetDBOnChange {
|
||
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
|
||
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer resetCancel()
|
||
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
|
||
stop.Dir = stackDir
|
||
if out, err := stop.CombinedOutput(); err != nil {
|
||
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
|
||
}
|
||
changed = true // a DB reset removed the container — it must be recreated
|
||
}
|
||
|
||
// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
|
||
// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
args := []string{"compose", "up", "-d", "--remove-orphans"}
|
||
if changed {
|
||
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
|
||
}
|
||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||
cmd.Dir = stackDir
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
||
} else if changed {
|
||
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
|
||
} else {
|
||
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
|
||
}
|
||
}
|
||
|
||
// fbPathDeps are the injectable edges of buildFileBrowserPaths — everything that would otherwise
|
||
// touch the real mount table, the real filesystem or a real statfs, so the A/B/C/D scenarios run
|
||
// without a drive, a NAS or docker.
|
||
type fbPathDeps struct {
|
||
isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint)
|
||
classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open)
|
||
ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY
|
||
// nsRootFor resolves a bare DRIVE path to its felhom-data namespace root (R-203). Injected rather
|
||
// than derived here because only the caller knows the system-data path. nil → identity, which is
|
||
// the pre-R-203 behaviour and correct for every enrolled drive.
|
||
nsRootFor func(string) string
|
||
logger *log.Logger
|
||
}
|
||
|
||
// buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list
|
||
// paths, with the per-kind gates applied. Two storage classes, two DIFFERENT gates:
|
||
//
|
||
// DRIVES (v0.66.0 semantics, unchanged byte-for-byte): scope to the `userdata/` subtree, pre-create
|
||
// the skeleton, and apply the drive-absent gate — a detached external drive must not get a skeleton
|
||
// written onto the rootfs. Drives always stay in the config source list (pre-R-67 behavior kept).
|
||
//
|
||
// NETWORK SHARES (R-67): the drive-absent gate does NOT apply — an idle automount trigger is
|
||
// HEALTHY (first access mounts it; the old gate skipped an idle share forever). The gate here is
|
||
// the STUB classifier instead, and it is a data-safety gate, not cosmetics: exposing a local stub
|
||
// dir lets a customer upload into a directory the real mount will later SHADOW — their files
|
||
// silently vanish from view. A stub share is excluded from BOTH the mounts and the source list
|
||
// this pass (a source without a mount is a broken sidebar entry). autofs-trigger / real network fs
|
||
// / unknown all include (unknown fails open — a wedged NAS must not hide the share forever).
|
||
// The bind is the share ROOT with :rslave — load-bearing: host-side automount wake and
|
||
// idle-unmount events must propagate into the RUNNING container (Phase-0 probe 2026-07-22 proved
|
||
// an in-container access through an rslave bind wakes the idle trigger). Never a skeleton, never
|
||
// any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS.
|
||
func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageMounts []string, configPaths []settings.StoragePath) {
|
||
configPaths = make([]settings.StoragePath, 0, len(paths))
|
||
for _, sp := range paths {
|
||
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"; ".../Felhom-Share" → "Felhom-Share"
|
||
if sp.IsNetwork() {
|
||
if d.classify != nil && d.classify(sp.Path) == system.FSClassStub {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[WARN] [web] FileBrowser: %s namespace sees a local stub, not the NAS — excluded until propagation recovers", mountName)
|
||
}
|
||
continue
|
||
}
|
||
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s:rslave", sp.Path, mountName))
|
||
configPaths = append(configPaths, sp)
|
||
continue
|
||
}
|
||
// Drives are ALWAYS in the source list (pre-R-67 behavior: the config listed every
|
||
// registered path; only the mount obeys the drive-absent gate).
|
||
configPaths = append(configPaths, sp)
|
||
// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
|
||
// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
|
||
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
|
||
// external-only rule (system paths, not under StableParentDir, are never skipped).
|
||
if skipFileBrowserPath(sp.Path, d.isMount) {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
|
||
}
|
||
continue
|
||
}
|
||
// SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata —
|
||
// app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted
|
||
// into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as
|
||
// uid 1000 → it can create folders + upload files (the old appdata mount was guest-root 0755
|
||
// → permission-denied). Pre-create the full skeleton with the convention.
|
||
if err := d.ensureSkeleton(sp.Path); err != nil {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
|
||
}
|
||
}
|
||
// R-203: UserdataDir takes a NAMESPACE ROOT. On the system-data fallback a bare drive path is
|
||
// one segment short, so FileBrowser mounted a directory that is not the one the skeleton
|
||
// builder creates or the backup captures — the customer would browse an empty tree.
|
||
nsRoot := sp.Path
|
||
if d.nsRootFor != nil {
|
||
nsRoot = d.nsRootFor(sp.Path)
|
||
}
|
||
userdataSrc := appbackup.UserdataDir(nsRoot)
|
||
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName))
|
||
}
|
||
return storageMounts, configPaths
|
||
}
|
||
|
||
// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
|
||
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
|
||
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
|
||
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
|
||
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
||
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
||
}
|
||
|
||
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
|
||
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
|
||
// pinned image and the base-infra bring-up path can never diverge).
|
||
func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
||
return infra.RenderFileBrowserCompose(domain, storageMounts)
|
||
}
|
||
|
||
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
||
// registered storage path. Delegates to internal/infra (single source of truth).
|
||
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||
return infra.RenderFileBrowserConfig(paths, importSource)
|
||
}
|