8fb2f9ef9d
gates / gates (push) Successful in 23s
Two defects in v0.254.0's globe, both plain on a browser and neither catchable by anything that existed — every test read the MARKUP, and the fault was in which CSS file the browser fetched. The shells requested /static/style.css with NO ?v=, while layout.html has carried one since v0.166.0. A browser holding a copy from before v0.254.0 kept serving CSS with no .lang-globe rules, so the globe came out as a bare unstyled <details> — a stray triangle and two plain words at the edge of the window. It was FIVE shells, not the three named: both guest share pages have the same fault for any CSS change, and their visitor is the likeliest of all to be holding an old copy. And .Version was missing from three of those five data maps, which is exactly how the next one would be forgotten — it is now filled at the one choke point every shell renders through. The globe also floated outside the card, pinned to the corner of the VIEWPORT, reading as part of the browser rather than the page. It now sits inside the card, centred under the footer, with the menu opening upward via the shared rule — so the dashboard and the shells cannot drift. AND A THIRD, caught by a test that already existed: putting the version on the guest share pages would have printed the controller build onto a page a stranger with a capability URL can open. TestShareGuest_HeadersTilesNoAdminChrome refused it. Those two now take an opaque per-build tag — same cache-busting, no disclosure. The fill is ONE function shared with the parity harness, because a fixture rendered through a different data path is a picture of a page nobody serves, which the previous release got wrong twice. 15 shell fixtures re-captured; 91 identical, every dashboard page among them. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1071 lines
48 KiB
Go
1071 lines
48 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"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 {
|
|
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
|
|
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
|
|
tmplByLang map[string]*template.Template
|
|
i18n *i18n.Bundle
|
|
|
|
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
|
|
|
|
// 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
|
|
}
|
|
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 == "/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)
|
|
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/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/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 == "/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.
|
|
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]
|
|
}
|
|
// 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)
|
|
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)
|
|
}
|