package web import ( "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "sync" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "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 logger *log.Logger version string tmpl *template.Template sessions map[string]*session sessionsMu sync.RWMutex done chan struct{} } func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, cpuCollector: cpuCollector, backupMgr: backupMgr, scheduler: sched, settings: sett, 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") } return s } func (s *Server) loadTemplates() { s.tmpl = template.Must( template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"), ) } // ServeHTTP handles all non-API web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path 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 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) default: http.NotFound(w, r) } } 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) } } // --- 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) { 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) 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) }