package web import ( "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "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/monitor" "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/storage" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) type Server struct { cfg *config.Config stackMgr *stacks.Manager cpuCollector *system.CPUCollector backupMgr *backup.Manager crossDriveRunner *backup.CrossDriveRunner 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 done chan struct{} // Disk operation state (format/migrate jobs) diskJobMu sync.Mutex diskJob *activeDiskJob // Active raw mount for the attach wizard (empty when not in use) activeRawMount string // Drive migration driveMigrator *storage.DriveMigrator // DR restore mode state restoreMu sync.RWMutex restorePlan *backup.RestorePlan // Storage watchdog (set after construction to break init ordering) storageWatchdog *monitor.StorageWatchdog // Hub push status callback — set via SetHubPushStatus for monitoring page hubPushStatusFn func() HubPushStatusData // Asset syncer for Hub-managed assets (optional) assetsSyncer *assets.Syncer // Debug mode support logBuffer *LogBuffer debugCallbacks *DebugCallbacks startTime time.Time } func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, 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, crossDriveRunner: crossDrive, scheduler: sched, settings: sett, alertManager: alertMgr, notifier: notif, updater: updater, logger: logger, version: version, sessions: make(map[string]*session), done: make(chan struct{}), } s.loadTemplates() go s.cleanupSessions() // Log auth source on startup if sett != nil && sett.GetPasswordHash() != "" { logger.Printf("[INFO] Auth: using password from settings.json") } else if cfg.Web.PasswordHash != "" { logger.Printf("[INFO] Auth: using password from controller.yaml") } else { logger.Printf("[INFO] Auth: no password configured — dashboard is open") } // Sync FileBrowser config on startup to ensure mounts and sources are current go s.SyncFileBrowserMounts() return s } // SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display. 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"), ) } // SetRestoreState puts the server into DR restore mode with the given plan. func (s *Server) SetRestoreState(plan *backup.RestorePlan) { s.restoreMu.Lock() defer s.restoreMu.Unlock() s.restorePlan = plan } // SetStorageWatchdog sets the storage watchdog for disconnect/reconnect operations. func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) { s.storageWatchdog = w } // SetDriveMigrator sets the drive migration engine for full drive migration. func (s *Server) SetDriveMigrator(dm *storage.DriveMigrator) { s.driveMigrator = dm } // 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 } // 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 } // 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). // Called from the mux carve-out; debug mode check is done here. func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) { if !s.isDebug() { http.NotFound(w, r) return } s.handleDebugAPI(w, r) } // InRestoreMode returns true if the server is in DR restore mode. func (s *Server) InRestoreMode() bool { s.restoreMu.RLock() defer s.restoreMu.RUnlock() return s.restorePlan != nil } // ServeHTTP handles all non-API web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // DR restore mode: intercept all routes except restore page, static, and restore API if s.InRestoreMode() { switch { case path == "/restore": s.restorePageHandler(w, r) return case path == "/api/restore/status": s.apiRestoreStatus(w, r) return case path == "/api/restore/all" && r.Method == http.MethodPost: s.apiRestoreAll(w, r) return case path == "/api/restore/skip" && r.Method == http.MethodPost: s.apiRestoreSkip(w, r) return case strings.HasPrefix(path, "/static/"): // Allow static assets through default: // Redirect everything else to the restore page http.Redirect(w, r, "/restore", http.StatusFound) return } } switch { case path == "/" || path == "/dashboard": s.dashboardHandler(w, r) case path == "/stacks": s.stacksHandler(w, r) case path == "/backups": s.backupsHandler(w, r) case path == "/monitoring": s.monitoringHandler(w, r) case path == "/settings": s.settingsHandler(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/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 strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost: name := strings.TrimPrefix(path, "/settings/cross-backup/") s.settingsCrossBackupHandler(w, r, name) case path == "/backup/restore" && r.Method == http.MethodPost: s.backupRestoreHandler(w, r) case path == "/settings/storage/init": s.storageInitHandler(w, r) case path == "/settings/storage/attach": s.storageAttachHandler(w, r) case path == "/settings/storage/migrate-drive": s.migrateDrivePageHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/migrate") s.migratePageHandler(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 path == "/static/style.css": s.serveCSSHandler(w, r) case path == "/static/chart.min.js": s.serveChartJSHandler(w, r) case path == "/static/felhom-logo.svg": s.serveLogoHandler(w, r) 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": if !s.isDebug() { http.NotFound(w, r) return } s.debugPageHandler(w, r) default: 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 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 } 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] 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 } // ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations). func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.storageAPIHandler(w, r) } // 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{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { s.logger.Printf("[ERROR] Template error (%s): %v", name, err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // 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) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { s.logger.Printf("[ERROR] Template error (%s): %v", name, err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // --- 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) } 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) } // 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) }