Files
felhom-controller/controller/internal/web/server.go
T

1160 lines
53 KiB
Go

package web
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
type Server struct {
// R-871: the missed-backup banner's inputs (SetMissedBackupBanner); nil = no banner.
nightLedger *nightchain.Ledger
metricsRecord *metrics.MetricsStore
cfg *config.Config
stackMgr *stacks.Manager
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
scheduler *scheduler.Scheduler
settings *settings.Settings
alertManager *AlertManager
notifier *notify.Notifier
updater *selfupdate.Updater
logger *log.Logger
version string
encKey []byte // AES-256 key for decrypting app.yaml values
// gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam.
gate gateState
fam familyState // v0.287.0: the family gate (family_gate.go)
familyStoreOverride *family.Store // test seam: the family list without a data dir
gateClock func() time.Time
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
versionPosition func(app string) (behind, climbable bool)
sessions map[string]*session
sessionsMu sync.RWMutex
loginAttempts map[string]*loginAttempt
loginAttemptMu sync.Mutex
done chan struct{}
closeOnce sync.Once
// Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share
// password gate — deliberately separate from loginAttempts (the admin login), so a guest and the
// owner never share a counter. Lazily initialized (struct-literal test servers skip NewServer).
shareAttempts map[string]*loginAttempt
shareAttemptMu sync.Mutex
// Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source
// (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now).
claimMu sync.Mutex
claimAttempts map[string]*claimAttempt
claimGlobal claimAttempt
claimClock func() time.Time
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
// fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts
// requests, fbDoneGen is the newest request a finished sync is known to have covered.
fbReqMu sync.Mutex
fbReqGen uint64
fbDoneGen uint64
// syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test).
syncFileBrowserHook func()
// langSwitchSync replaces the language switch's file-browser sync in tests (R-691).
langSwitchSync func()
// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
// this kills the per-call http.Transport leak that exhausted the controller's ephemeral ports to
// the agent's :8443 after ~5 days of uptime (see agentClient()).
agentCli *agentapi.Client
agentCliErr error
agentCliOnce sync.Once
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
// info page and the reveal endpoint — two ways to read the same secret is how one of them ends up
// caching it back into the page.
initialCredsFn func(stackName string) (*stacks.ExtractedCreds, error)
// Hub push status callback — set via SetHubPushStatus for monitoring page
hubPushStatusFn func() HubPushStatusData
// Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to
// report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local
// commit (escrow claim, notification/app-email save, offsite config/toggle, customer
// claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle.
// nil (hub reporting off / tests) = strict no-op.
reportTriggerFn func()
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
wipeStagedEscrowFn func(ctx context.Context) error
// Controller-driven escrow ceremony (v0.127.0) seams. escrowAgentFn nil → the shared
// agentClient(); escrowStageFn nil → PushOffboxPasswordForEscrow over the client;
// escrowStaleFn is the Scenario-F stale-blob flag (report.EscrowAutoConfirmer.StaleBlob,
// wired via SetEscrowStale; nil → never stale).
escrowAgentFn func() (escrowAgent, error)
escrowStageFn func(ctx context.Context) error
escrowStaleFn func() bool
// R-546: the cached agent-preflight readiness behind the reminder bar (escrow_readiness.go).
escrowReady escrowReadinessCache
// escrowSealedAtFn (v0.200.0, R-193) reports WHEN the hub's sealed recovery package was created —
// the one non-secret fact the recovery screen may state before a code is entered. Wired via
// SetEscrowSealedAt from the report ACK; nil → the page says nothing about the date rather than
// guessing one.
escrowSealedAtFn func() string
// recoveryRecovererFn is the recovery screen's agent seam (nil → the shared agentClient(), the
// same channel the CLI uses). Tests inject a fake so the HANDLER itself can be driven.
recoveryRecovererFn func() (backup.OffsiteKeyRecoverer, error)
// recoverySupportFn overrides the R-216 agent-capability verdict for the unlock path (tests).
// nil → the real gate over netFeatures. See recoverySupport: Unknown means CANNOT ASK here.
recoverySupportFn func(context.Context) agentapi.SupportState
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
// genuine refusal (tests). nil → the agent-version path.
recoveryRefusalTrustedFn func(context.Context) bool
// recoveryRetainedTrustedFn overrides the R-311 gate deciding whether a 422 may be read as "the
// code is correct and opens a RETAINED earlier package" (tests). INIT-ONLY.
recoveryRetainedTrustedFn func(context.Context) bool
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
// tests only — never a classifier.
recoveryNowFn func() time.Time
// recoveryTierUp brings the off-site tier up between placing a recovered key and reading the
// repository (R-219). Wired from main.go to the apply-bridge's Reconcile; nil → skipped, and the
// listing branch then reports what is pending rather than claiming a failure.
recoveryTierUp func(context.Context) error
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
netAdd netAddState
storageInit storageInitState
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
sambaEnsure sambaEnsureState
// sambaAddrFn is the connect-address seam (v0.151.0, S-2/S-5): the guest's LAN IPv4 for the
// „Csatlakozás a megosztáshoz" card, or "" when it cannot be read. nil → stackMgr.SambaLANAddress.
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
sambaAddrFn func() string
// guestGatewayFn / guestNetFn are the R-66 guest-network seams: the Hálózat card's gateway row
// and the Debug dump's network section. nil → stackMgr.GuestGateway / stackMgr.GuestNetSnapshot.
// Same S-5 law as sambaAddrFn: live-computed per render/dump, stored nowhere.
guestGatewayFn func() string
guestNetFn func() stacks.GuestNetSnapshot
netAgentFn func() (netAgent, error)
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
fabUpload uploadState
netProbeFn func(ctx context.Context, dir string) probeOutcome
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
// agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared
// agentClient().DebugLogs; tests inject (incl. the pre-0.83 typed-404 path).
agentLogsFn func(ctx context.Context) (agentapi.AgentLogsResponse, error)
// classifyFSPath classifies a network path's filesystem in THIS process's namespace (the stub
// badge, RCA fix 2). Set to system.ClassifyPathFSTimeout by NewServer; tests inject fake classes.
classifyFSPath func(path string) string
// netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add
// semantics — the add gate + the settings-page banner read it. Zero value ready.
netFeatures agentapi.SupportCache
// memFeatures caches the guest-memory-resize capability probe (agent v0.90.0, R-24) — the resize
// handler gate + the settings-page render read it. Zero value ready. memAgentFn is the test seam.
memFeatures agentapi.SupportCache
memAgentFn func() (memAgent, error)
// Asset syncer for Hub-managed assets (optional)
assetsSyncer *assets.Syncer
// App-to-app integration manager (optional).
// M25: atomic because the constructor launches the SyncFileBrowserMounts
// goroutine (which reads this) BEFORE SetIntegrationManager runs in main.go —
// so a plain field would be a data race (the init-only happens-before that
// covers the other Set* fields does NOT hold for this one).
integrationMgr atomic.Pointer[integrations.Manager]
// App export/import engine (optional)
appExporter *appexport.Exporter
// Whole-guest backup trigger (the quiesce loop; optional — nil on an unprovisioned guest or when
// quiesce is disabled). Drives the "Mentés most" button: the CONTROLLER owns quiescing, so the
// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
backupTrigger BackupTrigger
// Disk-health card + periodic degradation check (v0.169.0; ladder + persistence v0.215.0).
// diskHealth holds the 60s /disks TTL cache + the PERSISTED per-disk observation/alert history
// (disk_health_state.go). disksFn / diskNotifyFn are test seams (nil → the real agent client
// Disks() / the real notifier).
diskHealth diskHealthState
disksFn func(context.Context) (agentapi.DisksResponse, error)
diskNotifyFn func(notify.DiskAlert)
// tiersFn is the sibling test seam for the agent's backup-tier view (nil → the real client's
// BackupTiers()). Added with R-114 so the backup-target state — which is the source of a
// customer-facing banner — is testable through its REAL resolver rather than only through the
// pure copy helper. Without it the resolver's own branching had no test at all, which is how the
// configured-but-absent case reached production saying the wrong thing.
tiersFn func(context.Context) (agentapi.TiersResponse, error)
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
mailShim MailShimController
// Debug mode support
logBuffer *LogBuffer
debugCallbacks *DebugCallbacks
startTime time.Time
}
// MailShimController is the lifecycle handle the settings toggle uses to start/stop the
// app-email SMTP shim at runtime. Satisfied by *mailrelay.Lifecycle (kept as an interface
// to avoid a web→mailrelay import cycle risk and to allow a fake in tests).
type MailShimController interface {
Apply(enabled bool) error
Running() bool
}
// SetMailShim wires the app-email shim lifecycle (optional).
func (s *Server) SetMailShim(c MailShimController) { s.mailShim = c }
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
cpuCollector: cpuCollector,
backupMgr: backupMgr,
scheduler: sched,
settings: sett,
alertManager: alertMgr,
notifier: notif,
updater: updater,
logger: logger,
version: version,
sessions: make(map[string]*session),
loginAttempts: make(map[string]*loginAttempt),
shareAttempts: make(map[string]*loginAttempt),
done: make(chan struct{}),
}
s.classifyFSPath = system.ClassifyPathFSTimeout
if cfg.Logging.Level == "debug" {
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
logger.Printf("[DEBUG] [web] NewServer: backup=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
backupMgr != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
}
s.loadTemplates()
go s.cleanupSessions()
// .fab download staging (v0.124.0): sweep aged bundles left by a crash/abandoned download.
if cfg.Paths.DataDir != "" {
if n := sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, logger); n > 0 {
logger.Printf("[INFO] [web] download-export startup sweep removed %d staged bundle(s)", n)
}
}
// Drive-absent gate reconcile (intermediary-mount model): the periodic absent/return detector that
// replaced the retired slice-8C watchdog. Stops+blocks apps whose drive vanished, auto-restarts them
// when it returns. No-op when the agent is unreachable.
go s.driveGateLoop()
// Log auth source on startup
if sett != nil && sett.GetPasswordHash() != "" {
logger.Printf("[INFO] [web] Auth: using password from settings.json")
} else if cfg.Web.PasswordHash != "" {
logger.Printf("[INFO] [web] Auth: using password from controller.yaml")
} else {
logger.Printf("[INFO] [web] Auth: no password configured — dashboard is open")
}
// Sync FileBrowser config on startup to ensure mounts and sources are current.
// After a restore, a flag file signals that the database should be reset
// (stale source prefs from initial install). Consume the flag and reset.
fbResetFlag := filepath.Join(cfg.Paths.DataDir, ".fb-reset")
if _, err := os.Stat(fbResetFlag); err == nil {
os.Remove(fbResetFlag)
go s.SyncFileBrowserMountsReset()
} else {
go s.SyncFileBrowserMounts()
}
return s
}
// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
// Must be called before ListenAndServe (all Set* methods are init-time only).
func (s *Server) SetEncryptionKey(key []byte) {
s.encKey = key
}
// loadTemplates parses one template set per supported language (i18n, v0.247.0). Each set is parsed
// from the SAME embedded files after i18n.Expand has replaced their {{T "key"}} markers with that
// language's text — see internal/i18n for why the expansion happens before parsing and not at render.
// s.tmpl stays the Hungarian set, so every caller that predates i18n renders exactly what it did.
func (s *Server) loadTemplates() {
b, err := i18n.Shared()
if err != nil {
panic(err) // the bundles are embedded: a failure here is a broken build, like template.Must
}
s.i18n = b
s.tmplByLang = make(map[string]*template.Template, len(i18n.Supported))
for _, lang := range i18n.Supported {
funcs := s.templateFuncMap()
for name, fn := range s.localeFuncs(lang) {
funcs[name] = fn
}
for name, fn := range s.readerFuncs(lang, funcs) {
funcs[name] = fn
}
start := time.Now()
t, st, err := parseTemplateSet(b, lang, funcs)
took := time.Since(start)
if err != nil {
panic(fmt.Errorf("templates (%s): %w", lang, err))
}
s.tmplByLang[lang] = t
if lang != i18n.Default && len(st.FellBack) > 0 && s.logger != nil {
s.logger.Printf("[INFO] [web] i18n: %s template set shows Hungarian for %d markers (keys not yet translated)", lang, len(st.FellBack))
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] loadTemplates: lang=%s loaded %d templates, %d markers expanded in %s", lang, len(t.Templates()), st.Markers, took)
}
}
s.tmpl = s.tmplByLang[i18n.Default]
}
// parseTemplateSet is template.ParseFS(templateFS, "templates/*.html") with an i18n.Expand step
// between reading each file and parsing it. It names each file's template exactly as ParseFS does
// (the base name), so lookups behave identically.
func parseTemplateSet(b *i18n.Bundle, lang string, funcs template.FuncMap) (*template.Template, i18n.ExpandStats, error) {
var total i18n.ExpandStats
names, err := fs.Glob(templateFS, "templates/*.html")
if err != nil {
return nil, total, err
}
if len(names) == 0 {
return nil, total, fmt.Errorf("no templates embedded")
}
t := template.New("").Funcs(funcs)
for _, n := range names {
raw, err := templateFS.ReadFile(n)
if err != nil {
return nil, total, err
}
src, st := b.Expand(lang, string(raw))
total.Markers += st.Markers
total.FellBack = append(total.FellBack, st.FellBack...)
if len(st.Undefined) > 0 {
return nil, total, fmt.Errorf("%s: marker key(s) not in the %s bundle: %s", n, i18n.Default, strings.Join(st.Undefined, ", "))
}
base := path.Base(n)
tmpl := t
if base != t.Name() {
tmpl = t.New(base)
}
if _, err := tmpl.Parse(src); err != nil {
return nil, total, err
}
}
return t, total, nil
}
// templatesFor returns the template set for a language, the Hungarian set for anything unknown.
func (s *Server) templatesFor(lang string) *template.Template {
if t, ok := s.tmplByLang[lang]; ok {
return t
}
return s.tmpl
}
// langFor decides the language of one request. The order is fixed, and each step exists for a
// different reader (v0.254.0, R-557 release C):
//
// 1. `?lang=hu|en` — the testing override. Never persisted, never sets a cookie.
// 2. A REQUEST WITH A SESSION is the household's own: their saved setting, and the visitor cookie is
// NOT read. A signed-in household must never see a language a previous visitor chose on the
// sign-in page of the same browser.
// 3. A request with NO session is a visitor at the door — sign-in, claim, recovery. They have no
// setting to read and no right to change the household's, so their choice lives in their own
// browser: the `felhom_lang` cookie, display-only.
// 4. The household's setting anyway (a box whose visitor expressed no preference).
// 5. Hungarian.
//
// Why a cookie and not the setting: the sign-in page is reachable by anyone who can reach the box.
// Letting that change what the HOUSEHOLD reads would be an anonymous write to something they own.
// Changing what the VISITOR THEMSELVES reads is not — 04-control-plane-authorization.md.
func (s *Server) langFor(r *http.Request) string {
if r != nil {
if q := r.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
if !s.hasSession(r) {
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
return c.Value
}
}
}
if s.settings != nil {
return s.settings.GetLanguage()
}
return i18n.Default
}
// hasSession reports whether this request carries a VALID household session — the same test the auth
// middleware makes, reused rather than restated so the two cannot drift apart.
func (s *Server) hasSession(r *http.Request) bool {
if r == nil {
return false
}
c, err := r.Cookie(sessionCookieName)
return err == nil && s.isValidSession(c.Value)
}
// addShellAssetData fills the cache-buster fields every page shell's stylesheet link uses.
//
// ONE function, called by executeTemplateLang AND by the parity harness, because a fixture rendered
// through a different data path is a picture of a page nobody serves — which this release's own
// predecessor got wrong twice. Idempotent: an existing Version is left alone, so a fixture case that
// pins its own version keeps it.
//
// `AssetTag` is the buster for a page a STRANGER may open — the two guest share pages, reached by a
// capability URL. `TestShareGuest_HeadersTilesNoAdminChrome` refuses the version string on those for a
// reason worth keeping: telling anyone holding the link exactly which build is running is a gift to
// somebody looking for a known fault. An opaque per-build tag busts the cache just as well.
func (s *Server) addShellAssetData(data map[string]interface{}) {
if data == nil {
return
}
if _, ok := data["Version"]; !ok {
data["Version"] = s.version
}
data["AssetTag"] = s.assetTag()
}
// assetTag is an opaque per-build cache-buster: it changes when the version changes and discloses
// nothing about it. Used by the pages a stranger can open; everywhere else the version itself is
// already on the page and the tag would only be indirection.
func (s *Server) assetTag() string {
sum := sha256.Sum256([]byte("felhom-asset-tag-v1:" + s.version))
return hex.EncodeToString(sum[:4])
}
// HubPushStatusData holds hub push status for the monitoring page.
type HubPushStatusData struct {
LastAttempt time.Time
LastSuccess time.Time
LastError string
Consecutive int
}
// SetHubPushStatus sets the hub push status callback for the monitoring page.
func (s *Server) SetHubPushStatus(fn func() HubPushStatusData) {
s.hubPushStatusFn = fn
}
// SetAssetsSyncer sets the Hub asset syncer for resolving app assets.
func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
s.assetsSyncer = as
}
// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The
// provided func MUST be non-blocking — it is called from request handlers (the trigger's
// worker does the waiting/pushing). Init-time only, like every Set* here.
func (s *Server) SetReportTrigger(fn func()) {
s.reportTriggerFn = fn
}
// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors
// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never
// before it, never on an error path. Nil-safe no-op when hub reporting is off.
func (s *Server) reportTriggerNow() {
if s.reportTriggerFn != nil {
s.reportTriggerFn()
}
}
// SetIntegrationManager sets the app-to-app integration manager.
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
s.integrationMgr.Store(mgr)
}
// SetLogBuffer sets the in-memory log ring buffer for the debug log viewer.
func (s *Server) SetLogBuffer(lb *LogBuffer) {
s.logBuffer = lb
}
// SetDebugCallbacks sets the callbacks for debug endpoints that need main.go wiring.
func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
s.debugCallbacks = dc
}
// SetAppExporter sets the app export/import engine.
func (s *Server) SetAppExporter(e *appexport.Exporter) {
s.appExporter = e
}
// BackupTrigger forces an app-consistent whole-guest backup NOW (the quiesce loop satisfies it).
// Returns quiesce.ErrBackupInProgress when a cycle is already running (single-flight).
type BackupTrigger interface {
TriggerNow() error
}
// SetBackupTrigger wires the whole-guest backup trigger (the quiesce loop) for the "Mentés most"
// button. Optional — left nil on an unprovisioned guest or when quiesce is disabled (the button then
// renders disabled with an explanatory note).
func (s *Server) SetBackupTrigger(t BackupTrigger) {
s.backupTrigger = t
}
// SetStartTime records the controller start time for uptime calculation.
func (s *Server) SetStartTime(t time.Time) {
s.startTime = t
}
// isDebug returns true if the controller is running in debug mode.
func (s *Server) isDebug() bool {
return s.cfg.Logging.Level == "debug"
}
// ServeDebugAPI handles /api/debug/* routes (JSON API for debug operations).
// v0.116.1: NO logging-level gate — the capture ring always exists now, and gating
// the viewer on logging.level=debug was exactly the motivating incident's blind
// spot (an info box 404'd the whole debug surface). Auth: RequireAuth at the mux.
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
s.handleDebugAPI(w, r)
}
// ServeHTTP handles all non-API web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if s.isDebug() {
// The guest launcher share token (v0.165.0) is a secret — redact it from the request log so
// debug logging never leaks a live capability URL (Scenario G). Method + IP stay intact.
logPath := path
if strings.HasPrefix(path, "/s/") {
logPath = "/s/<redacted>"
}
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr)
}
// R-193: the recovery screen takes over the LANDING pages (and only those) while the situation
// holds and the customer has not postponed. Placed before the switch so it cannot be defeated by
// a route added later, and scoped to two paths so it never traps the customer inside it — every
// other page, including the backups area the entry point lives in, stays reachable.
if (path == "/launcher" || path == "/dashboard") && r.Method == http.MethodGet && s.recoveryInterrupts() {
http.Redirect(w, r, "/recovery", http.StatusFound)
return
}
switch {
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
case path == "/claim" && r.Method == http.MethodGet:
s.handleClaimPage(w, r, "", s.flashFrom(r, "flash"))
case path == "/claim" && r.Method == http.MethodPost:
s.handleClaimSubmit(w, r)
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
s.handleClaimRequestNewCode(w, r)
case path == "/":
// v0.170.0 (operator ruling reversing the v0.163.0 landing choice): the Indítópult is the
// canonical landing page. "/" 302s to /launcher (ONE canonical URL per page — the launcher body
// is never served AT "/"). Post-login lands on "/", so it flows here → the launcher.
http.Redirect(w, r, "/launcher", http.StatusFound)
// R-193 — the recovery screen. A FULL PAGE, not a banner: someone who has just lost a machine
// deserves a screen about that and nothing else. It takes over the landing pages while the
// situation holds AND the customer has not chosen "most nem"; afterwards it stays reachable here
// (and from the backups area) for as long as the situation lasts.
case path == "/recovery" && r.Method == http.MethodGet:
s.recoveryPageHandler(w, r)
case path == "/recovery/unlock" && r.Method == http.MethodPost:
s.recoveryUnlockHandler(w, r)
case path == "/recovery/postpone" && r.Method == http.MethodPost:
s.recoveryPostponeHandler(w, r)
// R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are
// SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop
// asking about this situation", and neither removes the entry point on the backups page.
case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost:
s.recoveryBannerDismissHandler(w, r)
case path == "/recovery/remind-optout" && r.Method == http.MethodPost:
s.recoveryRemindOptOutHandler(w, r)
case path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/launcher":
s.launcherHandler(w, r)
// Guest launcher share (v0.165.0). /s/<token> is the pre-auth capability URL (RequireAuth lets
// the /s/ prefix through after the claim gate). A GET renders the guest launcher (or the password
// gate); a POST submits the optional share password. An unknown/disabled token falls through to a
// byte-identical 404 (share404), so nothing distinguishes a wrong token from an unknown route.
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodGet:
s.shareGuestHandler(w, r)
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodPost:
s.shareGuestPasswordHandler(w, r)
// Admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect).
case path == "/launcher/share/qr.png" && r.Method == http.MethodGet:
s.launcherShareQRHandler(w, r)
case path == "/launcher/share/enable" && r.Method == http.MethodPost:
s.launcherShareEnableHandler(w, r)
case path == "/launcher/share/rotate" && r.Method == http.MethodPost:
s.launcherShareRotateHandler(w, r)
case path == "/launcher/share/disable" && r.Method == http.MethodPost:
s.launcherShareDisableHandler(w, r)
case path == "/launcher/share/password" && r.Method == http.MethodPost:
s.launcherSharePasswordHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(w, r)
case path == "/backups/window" && r.Method == http.MethodPost:
s.backupWindowSaveHandler(w, r)
// v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working —
// /backups itself is the Áttekintés page).
case path == "/backups/remote":
s.backupsRemoteHandler(w, r)
case path == "/backups/apps":
s.backupsAppsHandler(w, r)
case path == "/backups/restore":
s.backupsRestoreHandler(w, r)
// R-48: the per-app offsite restore wizard. A GET-only page — every action inside it posts to the
// pre-existing /backup/offbox/* endpoints, so this adds no mutation surface.
case path == "/backups/restore/app" && r.Method == http.MethodGet:
s.backupsRestoreWizardHandler(w, r)
case path == "/monitoring":
s.monitoringHandler(w, r)
case path == "/settings":
s.settingsHandler(w, r)
case path == "/storage" && r.Method == http.MethodGet:
s.storagePageHandler(w, r)
case path == "/kept-data" && r.Method == http.MethodGet:
s.keptPageHandler(w, r)
case path == "/kept-data/delete" && r.Method == http.MethodPost:
s.keptDeleteHandler(w, r)
case path == "/kept-data/load" && r.Method == http.MethodPost:
s.keptLoadHandler(w, r)
case path == "/storage/network" && r.Method == http.MethodGet:
s.storageNetworkPageHandler(w, r)
// „Megosztás" — LAN network sharing (R-7 slice 1)
case path == "/sharing" && r.Method == http.MethodGet:
s.sharingPageHandler(w, r)
case path == "/sharing/enable" && r.Method == http.MethodPost:
s.sharingEnableHandler(w, r)
case path == "/sharing/status" && r.Method == http.MethodGet:
s.sharingStatusHandler(w, r)
case path == "/sharing/password" && r.Method == http.MethodPost:
s.sharingPasswordHandler(w, r)
case path == "/sharing/shares" && r.Method == http.MethodPost:
s.sharingShareCreateHandler(w, r)
case path == "/sharing/shares/delete" && r.Method == http.MethodPost:
s.sharingShareDeleteHandler(w, r)
case path == "/sharing/shares/offsite" && r.Method == http.MethodPost:
s.sharingShareOffsiteHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodGet:
s.settingsNotificationsPageHandler(w, r)
// v0.287.0 (decisions 63/64): the „Család" card's member list and its three acts (session + CSRF like every POST).
case path == "/family/members" && r.Method == http.MethodGet:
s.familyMembersHandler(w, r)
case strings.HasPrefix(path, "/family/members/") && r.Method == http.MethodPost:
s.familyMemberActionHandler(w, r, strings.TrimPrefix(path, "/family/members/"))
case path == "/settings/security" && r.Method == http.MethodGet:
s.settingsSecurityPageHandler(w, r)
case path == "/settings/password" && r.Method == http.MethodPost:
s.settingsPasswordHandler(w, r)
// R-249: the retrieval passphrase is fetched by an explicit authenticated act, never templated
// into the security page. POST (not GET) so it is CSRF-covered and uncacheable — see the handler.
case path == "/settings/retrieval-password/reveal" && r.Method == http.MethodPost:
s.settingsRetrievalPasswordRevealHandler(w, r)
// i18n (v0.247.0): the household's dashboard language.
case path == "/settings/language" && r.Method == http.MethodPost:
s.languageSwitchHandler(w, r)
// i18n (v0.254.0): the VISITOR's display language, for a page with no household signed in.
// RequireAuth intercepts this on a password-protected box; the route is here so it also works on
// a box with no password set and on an unclaimed one, where the middleware passes straight
// through. Same handler either way.
case path == langCookiePath && r.Method == http.MethodPost:
s.langCookieHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodPost:
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
s.settingsNotificationsTestHandler(w, r)
case path == "/settings/app-email" && r.Method == http.MethodPost:
s.settingsAppEmailHandler(w, r)
case path == "/settings/app-update" && r.Method == http.MethodPost:
s.settingsAppUpdateHandler(w, r)
case path == "/settings/storage/add" && r.Method == http.MethodPost:
s.settingsStorageAddHandler(w, r)
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
s.settingsStorageRemoveHandler(w, r)
case path == "/settings/storage/default" && r.Method == http.MethodPost:
s.settingsStorageDefaultHandler(w, r)
case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
s.settingsStorageSchedulableHandler(w, r)
case path == "/settings/storage/label" && r.Method == http.MethodPost:
s.settingsStorageLabelHandler(w, r)
case path == "/storage/init" && r.Method == http.MethodGet:
s.storageWizardPageHandler(w, r, "storage_init")
case path == "/storage/attach" && r.Method == http.MethodGet:
s.storageWizardPageHandler(w, r, "storage_attach")
// D1: the wizard pages moved under /storage — permanent redirects keep old links working.
case path == "/settings/storage/init" && r.Method == http.MethodGet:
http.Redirect(w, r, "/storage/init", http.StatusMovedPermanently)
case path == "/settings/storage/attach" && r.Method == http.MethodGet:
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r)
// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
s.backupTier2RestoreHandler(w, r)
// R-102/R-103: the DESTRUCTIVE twin — a full recovery-unit restore read from the SECOND DRIVE's
// mirror. Separate route because it is a separate promise: this one overwrites live data.
case path == "/backup/tier2/unit-restore" && r.Method == http.MethodPost:
s.backupTier2UnitRestoreHandler(w, r)
// Off-box (NAS) restic-SFTP backup (Part B)
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
s.offboxConfigHandler(w, r)
case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
s.offboxToggleHandler(w, r)
case path == "/backup/offbox/enable-all" && r.Method == http.MethodPost: // decision 50
s.offboxEnableAllHandler(w, r)
case path == "/backup/offbox/offer-dismiss" && r.Method == http.MethodPost: // decision 50
s.offboxOfferDismissHandler(w, r)
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
s.offboxRunHandler(w, r)
case path == "/backup/offbox/reset" && r.Method == http.MethodPost:
s.offboxResetHandler(w, r)
case path == "/backup/offbox/status" && r.Method == http.MethodGet:
s.offboxStatusHandler(w, r)
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
s.offboxRestoreHandler(w, r)
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
s.offboxPlaceHandler(w, r)
case path == "/backup/offbox/reconstitute" && r.Method == http.MethodPost:
s.offboxReconstituteHandler(w, r)
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
// DeleteOffsiteRestoreCopy for the prefix guard.
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
s.offboxVerifyCopyDeleteHandler(w, r)
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
s.sharesRestoreHandler(w, r)
case path == "/backup/shares/place" && r.Method == http.MethodPost:
s.sharesPlaceHandler(w, r)
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
case path == "/backups/missed-banner/dismiss" && r.Method == http.MethodPost:
s.missedBackupBannerDismissHandler(w, r)
case path == "/backup/escrow/banner/dismiss" && r.Method == http.MethodPost:
s.escrowBannerDismissHandler(w, r)
case path == "/backup/escrow" && r.Method == http.MethodGet:
s.escrowWizardPageHandler(w, r)
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
s.offboxConfirmEscrowHandler(w, r)
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
s.offboxInjectPasswordHandler(w, r)
// R-254 site two: an already-deployed app's generated secrets are fetched by an explicit act,
// not rendered into the settings page. The PRE-DEPLOY hidden input is untouched and deliberate
// (README §318) — see the handler for what §7.2 established.
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/auto-field/reveal") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/auto-field/reveal")
s.appAutoFieldRevealHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/export")
s.exportPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
s.logsHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigSaveHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/app-email") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/app-email")
s.appEmailToggleHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css":
s.serveCSSHandler(w, r)
case path == "/static/chart.min.js":
s.serveChartJSHandler(w, r)
case strings.HasPrefix(path, "/static/fonts/"):
s.serveFontHandler(w, r, strings.TrimPrefix(path, "/static/fonts/"))
case path == "/static/felhom-logo.svg":
s.serveLogoHandler(w, r)
case path == "/static/favicon.svg":
s.serveFaviconHandler(w, r)
case path == "/static/infra-logo.svg":
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, InfraLogoSVG)
case path == "/static/app-placeholder.svg":
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, AppPlaceholderSVG)
case strings.HasPrefix(path, "/static/assets/"):
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
// R-254: the app's generated first-login password is fetched by an explicit authenticated act,
// never templated into the info page. Placed BEFORE the /apps/ catch-all so the more specific
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
// v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/close-signup") && r.Method == http.MethodPost:
s.appCloseSignupHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/close-signup"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/signup-window") && r.Method == http.MethodPost:
s.appSignupWindowHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/signup-window"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/default-login/changed") && r.Method == http.MethodPost:
s.appDefaultLoginChangedHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/default-login/changed"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal")
s.appInitialCredsRevealHandler(w, r, slug)
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
s.appDetailHandler(w, r, slug)
case path == "/debug":
// v0.116.1: available at ANY logging.level (the ring always captures; see ServeDebugAPI).
s.debugPageHandler(w, r)
default:
s.logger.Printf("[WARN] [web] 404 Not Found: %s %s", r.Method, path)
http.NotFound(w, r)
}
}
// CatchAllMiddleware intercepts requests to non-controller hosts and serves
// a branded error page (for stopped/undeployed app subdomains). Requests to
// the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
controllerHost := "felhom." + s.cfg.Customer.Domain
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// v0.280.0 (decision 46): traefik's forwardAuth call for a gated app arrives with the controller's own
// container name as Host, so it is answered before the host check. The start page is the dashboard's.
if r.URL.Path == gateAuthPath {
s.ServeGateAuth(w, r)
return
}
if r.URL.Path == familyAuthPath { // v0.287.0 (decisions 63/64): the family gate's forwardAuth, any host
s.ServeFamilyGateAuth(w, r)
return
}
if strings.EqualFold(host, controllerHost) {
switch r.URL.Path { // v0.287.0: the family sign-in pages, outside the dashboard's own auth on purpose
case familyStartPath:
s.ServeFamilyStart(w, r)
return
case familyLoginPath:
s.ServeFamilyLogin(w, r)
return
case familyLogoutPath:
s.ServeFamilyLogout(w, r)
return
}
}
if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed
s.ServeSignupClosed(w, r)
return
}
if r.URL.Path == gateStartPath && strings.EqualFold(host, controllerHost) && r.Method == http.MethodGet {
s.ServeGateStart(w, r)
return
}
// Pass through: controller host, localhost (healthcheck/internal), or empty
if strings.EqualFold(host, controllerHost) || host == "" ||
host == "localhost" || host == "127.0.0.1" {
next.ServeHTTP(w, r)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
}
s.serveCatchAll(w, r, host)
})
}
// serveCatchAll renders a branded page for requests reaching a stopped/undeployed
// app subdomain. Served without auth since the user has no session on this host.
func (s *Server) serveCatchAll(w http.ResponseWriter, r *http.Request, host string) {
domain := s.cfg.Customer.Domain
subdomain := ""
suffix := "." + domain
if strings.HasSuffix(host, suffix) {
subdomain = strings.TrimSuffix(host, suffix)
}
data := map[string]interface{}{
"Domain": domain,
"ControllerURL": "https://felhom." + domain,
"Host": host,
}
if subdomain != "" {
if stack, ok := s.findStackBySubdomain(subdomain); ok {
data["AppName"] = stack.Meta.DisplayName
data["AppSlug"] = stack.Meta.Slug
data["AppLogoURL"] = s.cfg.AppLogoURL(stack.Meta.Slug)
if stack.Deployed {
data["Status"] = "stopped"
data["StatusText"] = "Az alkalmazás jelenleg le van állítva"
} else {
data["Status"] = "not_deployed"
data["StatusText"] = "Az alkalmazás nincs telepítve"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
if err := s.executeTemplateLang(w, r, "catchall", data); err != nil {
s.logger.Printf("[ERROR] [web] Catch-all template error: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// findStackBySubdomain looks up the stack that owns the given subdomain.
func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
for _, stack := range s.stackMgr.GetStacks() {
// Check deployed app.yaml SUBDOMAIN env first
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
return &stack, true
}
}
}
// Fallback to metadata subdomain
if stack.Meta.Subdomain == subdomain {
return &stack, true
}
}
return nil, false
}
// primaryHDDPath returns the default storage path, or the legacy config value.
func (s *Server) primaryHDDPath() string {
if p := s.settings.GetDefaultStoragePath(); p != "" {
return p
}
return s.cfg.Paths.HDDPath
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
// executeTemplate renders a template with CSRF data auto-injected into the data map.
// Use this instead of render() for all authenticated page handlers.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
// R-543 — the escrow reminder hangs HERE, on the single render choke point, so it reaches every
// dashboard page instead of the three pages a per-handler call would have covered. The login,
// claim, recovery, guest-share and catch-all pages render through executeTemplateLang and never pass
// through here (TestI18nDirectRenderPagesHaveNoAdminChrome); the session check inside is a second
// fence for the guest share page, which has no admin session.
s.addEscrowBanner(data, r)
s.addMissedBackupBanner(data, r) // R-871 (decision 110)
lang := s.langFor(r)
s.addLanguageData(data, r, lang)
var buf bytes.Buffer
if err := s.templatesFor(lang).ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
// executeTemplateLang renders a page that stands OUTSIDE the dashboard chrome — sign-in, claim,
// recovery, the guest share pages, the catch-all — in the request's language (slice 1, R-556).
// Deliberately NOT executeTemplate: that one injects the session CSRF fields and the escrow reminder bar
// (R-543), which a guest or a signed-out visitor must never receive. It only picks the language set and
// tells the page which language it is in. The caller keeps its own headers and error handling.
func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string, data map[string]interface{}) error {
lang := s.langFor(r)
if data != nil {
data["Lang"] = lang
// v0.255.0: the stylesheet cache-buster, set HERE rather than in each handler.
//
// Five shells loaded /static/style.css with no `?v=`, so a browser that had the file cached
// kept serving the old one — and the v0.254.0 globe rendered as a bare, unstyled <details>
// for anyone who had visited before. Three of the five did not carry `Version` in their data
// at all, which is exactly how the next one would be forgotten: setting it at the single choke
// point every shell renders through means a new shell cannot miss it.
s.addShellAssetData(data)
// v0.254.0: the globe, on a page rendered outside the dashboard chrome. WHICH FORM it posts to
// depends on WHO is reading, and getting that wrong makes the globe do nothing:
//
// * NO SESSION — the sign-in and claim pages. The reader is a visitor: they have no setting
// and may not write the household's, so the globe posts to /lang and their choice lives in
// their own browser. No CSRF field: there is no session to mint one from.
// * WITH A SESSION — /recovery is in the AUTHENTICATED route table; its reader IS the
// household. Their globe must write their SETTING, because langFor deliberately ignores the
// cookie once there is a session — so an anonymous form here would set a cookie nothing
// reads and the button would appear to do nothing. Measured live on demo-hp before this
// branch existed.
//
// Only the language form's own fields are added, never the dashboard chrome R-543 keeps off
// these pages (TestI18nDirectRenderPagesHaveNoAdminChrome still passes: it names CSRFField,
// CSRFToken and the escrow banner, none of which appear here).
back := "/"
if r != nil && r.URL != nil && r.URL.Path != "" {
back = r.URL.Path
}
if s.hasSession(r) {
addLangOptions(data, lang, "/settings/language", back, s.csrfField(r))
} else {
addLangOptions(data, lang, langCookiePath, back, "")
}
}
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
}
// --- Static file / asset serving ---
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
data, err := templateFS.ReadFile("templates/style.css")
if err != nil {
http.Error(w, "CSS not found", 500)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(chartJS)
}
// serveFontHandler serves the vendored woff2 files embedded in the binary
// (self-hosted fonts — no Google Fonts CDN dependency on offline nodes).
func (s *Server) serveFontHandler(w http.ResponseWriter, r *http.Request, filename string) {
filename = filepath.Base(filename)
data, err := fontFS.ReadFile("static/fonts/" + filename)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "font/woff2")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(data)
}
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
// Try synced asset first (allows logo updates via Hub without rebuild)
if s.assetsSyncer != nil {
path := s.assetsSyncer.Resolve("felhom-logo.svg")
if _, err := os.Stat(path); err == nil {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
return
}
}
// Fallback to embedded logo
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, FelhomLogoSVG)
}
func (s *Server) serveFaviconHandler(w http.ResponseWriter, r *http.Request) {
// Try synced asset first
if s.assetsSyncer != nil {
path := s.assetsSyncer.Resolve("felhom-favicon.svg")
if _, err := os.Stat(path); err == nil {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
return
}
}
// Fallback to embedded favicon
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, FelhomFaviconSVG)
}
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
const assetsDir = "/usr/share/felhom/assets"
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
filename = filepath.Base(filename)
var path string
if s.assetsSyncer != nil {
path = s.assetsSyncer.Resolve(filename)
} else {
path = filepath.Join(assetsDir, filename)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
}