Files
felhom-controller/controller/internal/web/server.go
T
admin 9cc7ffcf3b
gates / gates (push) Failing after 44s
A version number is pushed once, and every build addresses its CSS differently
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.

What it was, in four measured facts:
  1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
     0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
  2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
     cached copy cannot be revalidated - it can only expire;
  3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
     from the origin's 3600 to 14400 - four hours;
  4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
     holds an entry that has aged out.

Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.

THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:

build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).

assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.

The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:08:01 +02:00

1239 lines
58 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/mediawire"
"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
// buildID distinguishes two builds of the SAME version — see SetBuildIdentity and assetTag.
buildID 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
// fbForce (R-1038): the next FileBrowser sync recreates even when nothing changed (a drive came back).
fbForceMu sync.Mutex
fbForce bool
// media drive events (v0.313.0): the wiring (R-1024) and the „restart later" timer.
mediaMu sync.Mutex
mediaWiring []mediawire.Result
mediaWiredAt time.Time
mediaLaterAt time.Time
mediaLaterTmr *time.Timer
mediaWirer *mediawire.Wirer
mediaSoonTmr *time.Timer
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
// R-304 option C: the operator's older-package mail — the test seam, and the once-per-day record (recovery_older_mail.go).
recoveryOlderPushFn func(eventType, severity, message string, details map[string]string)
recoveryOlderMu sync.Mutex
recoveryOlderLastDay string
// 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)
// R-998: test seams for the power-off route (nil in production → the real agent client / busy checks).
powerOffAgentFn func() (powerOffAgent, error)
powerOffBusyFn func() (bool, string)
// 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()
s.loadSessions() // R-35: dashboard sign-ins survive the controller's own restart (session_store.go)
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 formatFuncs(lang) { // R-562: dates and numbers in this language
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 EVERY shell's stylesheet link uses (v0.316.0). It was only on the two
// stranger-facing guest share pages; the rest asked for `?v={{.Version}}`, which does not change
// between two builds of one version — the defect Part 0 measured. `Version` stays in the data for
// the pages that display it.
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()
}
// SetBuildIdentity records which BUILD this binary is, as opposed to which version it calls itself.
// Init-only, called once from main before the HTTP server starts (the `SetStackProvider` contract).
//
// It exists because a version number is not a build. v0.315.0 was built and pushed three times from
// three different commits; every one of them asked the browser for `/static/style.css?v=0.315.0`,
// so a cache that already held the first build's stylesheet kept serving it — measured through
// Cloudflare (`audits/app-page-2026-10-15b/P0/VERDICT.md`). build.sh now refuses a second push of a
// version, and this makes the asset URL change even when the version does not.
func (s *Server) SetBuildIdentity(gitCommit, buildTime string) {
s.buildID = gitCommit + "/" + buildTime
}
// assetTag is an opaque per-BUILD cache-buster: it changes whenever the binary changes, and
// discloses neither the version nor the commit.
//
// Two jobs in one value, and both need it opaque:
// - every shell's stylesheet link uses it, so no cache at any layer (browser, Cloudflare, or
// anything between) can serve a previous build's CSS under an address the new build still asks
// for. The origin sends no ETag and no Last-Modified, so a stale copy cannot be revalidated —
// it can only expire, or be addressed differently. This is the "addressed differently".
// - the pages a STRANGER can open use it too. `TestShareGuest_HeadersTilesNoAdminChrome` refuses
// the version string there for a reason worth keeping: telling anyone holding a capability URL
// exactly which build is running is a gift to somebody looking for a known fault.
//
// Deterministic for a given binary, so the i18n fixtures stay stable within a build; the parity
// harness normalises it anyway, because a fixture pins COPY, not which build rendered it.
func (s *Server) assetTag() string {
sum := sha256.Sum256([]byte("felhom-asset-tag-v1:" + s.version + ":" + s.buildID))
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/clear" && r.Method == http.MethodPost: // R-729 / R-545
s.offboxClearHandler(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)
// v0.315.0 — THE OLD APP URLS. Each of these four was a page of its own until the app page took
// their content into a tab. An alert e-mail, a bookmark or a tab open since before the update
// still carries them, so each GET answers 302 to the tab that now holds it; the query string
// travels too (`?raw=1` is a logs poller, `?flash=…` is a save coming back).
//
// 302 AND NOT 301, deliberately: a 301 lives in the browser's cache beyond anyone's reach, and
// these paths are a bridge for one release, not an address.
//
// The METHOD CHECK is the load-bearing half. `/stacks/{name}/backup` POST is the backup form,
// `/app-email` POST is the toggle, `/auto-field/reveal` POST reads a secret — redirecting any of
// them would turn a save into a GET of a page and lose the household's input. Each POST case
// below is therefore matched on its method, and the GET redirects never see one.
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export") && r.Method == http.MethodGet:
s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/export"), appTabData)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs") && r.Method == http.MethodGet:
s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/logs"), appTabLogs)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy") && r.Method == http.MethodGet:
s.redirectStackDeployGET(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/deploy"))
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup"), appTabData)
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 path == "/apps/plex/plex-claim" && r.Method == http.MethodPost:
s.appPlexClaimHandler(w, r)
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"))
// R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it.
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/lost-files/dismiss") && r.Method == http.MethodPost:
s.appLostFilesDismissHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/lost-files/dismiss"))
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)
// v0.315.0: the household's answer to an after-start field (Part 4). POST, so CsrfProtect covers
// it; the registry decides what runs, and an unregistered field is a 404 that executes nothing.
case strings.HasPrefix(path, "/apps/") && strings.Contains(path, "/after-start/") && r.Method == http.MethodPost:
rest := strings.TrimPrefix(path, "/apps/")
slug, env, _ := strings.Cut(rest, "/after-start/")
s.appAfterStartHandler(w, r, slug, env)
// v0.315.0: the app page and its tabs. Placed after every /apps/ POST case above and before the
// catch-all, so a more specific path always wins.
case strings.HasPrefix(path, "/apps/") && r.Method == http.MethodGet:
slug, tab, known := appTabPathSuffix(strings.TrimPrefix(path, "/apps/"))
switch {
case !known:
// An unknown suffix is not a tab, and it is not part of the slug either.
s.logger.Printf("[WARN] [web] 404 Not Found: %s %s (no such app tab)", r.Method, path)
http.NotFound(w, r)
case tab == appTabInstall:
s.appInstallPageHandler(w, r, slug)
default:
s.appPageHandler(w, r, slug, tab)
}
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
s.appPageHandler(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 ("" when none is registered — R-492 deleted the
// always-empty legacy config value it used to fall back to).
func (s *Server) primaryHDDPath() string {
return s.settings.GetDefaultStoragePath()
}
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)
}