Files
felhom-controller/controller/internal/web/server.go
T
admin 987e915bf2 Indítópult launcher page + universal app placeholder icon (v0.163.0)
New /launcher page: a grid of large tappable tiles, one per openable deployed
app (subdomain presence is the single openability criterion, shared with the
Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles
(deterministic slug color or .felhom.yml brand_color), white glyph/monogram
fallback, target=_blank links for operational apps, greyed unclickable tiles for
stopped ones. First sidebar item; / stays the Vezérlőpult.

Universal app placeholder: new AppPlaceholderSVG served at
/static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a
logo-less app shows a placeholder instead of visibility:hidden. Brand mark is
never an app placeholder.

New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions +
4 red-proofs. No agent coupling; MinAgent unchanged.
2026-07-24 09:16:28 +02:00

727 lines
30 KiB
Go

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
// 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),
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() {
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, 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)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(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)
}