package web import ( "bytes" "context" "fmt" "html/template" "log" "net/http" "os" "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/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 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 // 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 // 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 // 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 } func (s *Server) loadTemplates() { s.tmpl = template.Must( template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"), ) if s.isDebug() { names := s.tmpl.Templates() s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names)) } } // 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/" } s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr) } 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, "", r.URL.Query().Get("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 == "/" || path == "/dashboard": s.dashboardHandler(w, r) case path == "/launcher": s.launcherHandler(w, r) // Guest launcher share (v0.165.0). /s/ 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) 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) // 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" && 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) 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/")) 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.tmpl.ExecuteTemplate(w, "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) 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) } // --- 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) }