package web import ( "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "golang.org/x/crypto/bcrypt" ) type Server struct { cfg *config.Config stackMgr *stacks.Manager logger *log.Logger version string tmpl *template.Template sessions map[string]*session sessionsMu sync.RWMutex } type session struct { token string expiresAt time.Time } const ( sessionCookieName = "felhom_session" sessionMaxAge = 24 * time.Hour ) func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, logger: logger, version: version, sessions: make(map[string]*session), } s.loadTemplates() go s.cleanupSessions() return s } func (s *Server) loadTemplates() { funcMap := template.FuncMap{ "stateColor": func(state stacks.ContainerState) string { switch state { case stacks.StateRunning: return "green" case stacks.StateStarting: return "orange" case stacks.StateUnhealthy: return "yellow" case stacks.StateStopped, stacks.StateExited: return "red" case stacks.StateRestarting: return "yellow" default: return "gray" } }, "stateLabel": func(state stacks.ContainerState) string { switch state { case stacks.StateRunning: return "Fut" case stacks.StateStarting: return "Indulás..." case stacks.StateUnhealthy: return "Nem egészséges" case stacks.StateStopped, stacks.StateExited: return "Leállítva" case stacks.StateRestarting: return "Újraindítás..." case stacks.StateNotDeployed: return "Nincs telepítve" case stacks.StatePaused: return "Szüneteltetve" default: return "Ismeretlen" } }, "stateIcon": func(state stacks.ContainerState) string { switch state { case stacks.StateRunning: return "●" case stacks.StateStarting: return "◐" case stacks.StateUnhealthy: return "◑" case stacks.StateStopped, stacks.StateExited: return "○" case stacks.StateRestarting: return "◐" default: return "◌" } }, "stateStr": func(state stacks.ContainerState) string { return string(state) }, // isOperational returns true for any state where the stack has containers // and is not stopped/exited — used by templates for showing action buttons "isOperational": func(state stacks.ContainerState) bool { switch state { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: return true default: return false } }, "logoURL": func(slug string) string { return s.cfg.AppLogoURL(slug) }, "logoPNGURL": func(slug string) string { return s.cfg.AppLogoPNGURL(slug) }, "appPageURL": func(slug string) string { return s.cfg.AppPageURL(slug) }, "usageColor": func(percent float64) string { if percent >= 85 { return "red" } if percent >= 70 { return "yellow" } return "green" }, "fmtMB": func(mb uint64) string { if mb >= 1024 { gb := float64(mb) / 1024.0 if gb >= 10 { return fmt.Sprintf("%.0f GB", gb) } return fmt.Sprintf("%.1f GB", gb) } return fmt.Sprintf("%d MB", mb) }, "fmtGB": func(gb float64) string { if gb >= 100 { return fmt.Sprintf("%.0f GB", gb) } if gb >= 10 { return fmt.Sprintf("%.1f GB", gb) } return fmt.Sprintf("%.2f GB", gb) }, "subtract": func(a, b int) int { r := a - b if r < 0 { return 0 } return r }, "screenshotURL": func(slug string, index int) string { return s.cfg.AppScreenshotURL(slug, index) }, "seq": func(n int) []int { result := make([]int, n) for i := range result { result[i] = i + 1 } return result }, } s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates)) } // 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 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": w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "public, max-age=3600") fmt.Fprint(w, cssContent) case path == "/static/felhom-logo.svg": w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") fmt.Fprint(w, felhomLogoSVG) 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) } } // RequireAuth returns middleware that checks for valid session or shows login. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password is configured if s.cfg.Web.PasswordHash == "" { next.ServeHTTP(w, r) return } if r.URL.Path == "/api/health" { next.ServeHTTP(w, r) return } if r.URL.Path == "/login" && r.Method == http.MethodPost { s.handleLogin(w, r) return } if r.URL.Path == "/login" { s.renderLogin(w, "") return } if r.URL.Path == "/logout" { s.handleLogout(w, r) return } cookie, err := r.Cookie(sessionCookieName) if err != nil || !s.isValidSession(cookie.Value) { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`) return } http.Redirect(w, r, "/login", http.StatusFound) return } next.ServeHTTP(w, r) }) } // --- Auth helpers --- func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() password := r.FormValue("password") if password == "" { s.renderLogin(w, "Kérjük adja meg a jelszót") return } if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil { s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) s.renderLogin(w, "Hibás jelszó") return } token := s.createSession() http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", MaxAge: int(sessionMaxAge.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: true, }) s.logger.Printf("[INFO] Login from %s", r.RemoteAddr) http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(sessionCookieName); err == nil { s.sessionsMu.Lock() delete(s.sessions, cookie.Value) s.sessionsMu.Unlock() } http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/login", http.StatusFound) } func (s *Server) createSession() string { b := make([]byte, 32) _, _ = rand.Read(b) token := hex.EncodeToString(b) s.sessionsMu.Lock() s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)} s.sessionsMu.Unlock() return token } func (s *Server) isValidSession(token string) bool { s.sessionsMu.RLock() defer s.sessionsMu.RUnlock() sess, ok := s.sessions[token] if !ok || time.Now().After(sess.expiresAt) { return false } return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1 } func (s *Server) cleanupSessions() { for range time.Tick(15 * time.Minute) { s.sessionsMu.Lock() now := time.Now() for t, sess := range s.sessions { if now.After(sess.expiresAt) { delete(s.sessions, t) } } s.sessionsMu.Unlock() } } // --- Page handlers --- func (s *Server) baseData(page, title string) map[string]interface{} { return map[string]interface{}{ "Page": page, "Title": title, "CustomerName": s.cfg.Customer.Name, "Domain": s.cfg.Customer.Domain, "Version": s.version, } } func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { stackList := s.stackMgr.GetStacks() running, stopped := 0, 0 for _, st := range stackList { switch st.State { case stacks.StateRunning: running++ case stacks.StateStopped, stacks.StateExited: stopped++ case stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: // Count starting/unhealthy/restarting as "running" for the dashboard stat // (they have containers, they're just not fully healthy yet) running++ } } sysInfo := system.GetInfo(s.cfg.Paths.HDDPath) data := s.baseData("dashboard", "Vezérlőpult") data["Stacks"] = stackList data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) data["SystemInfo"] = sysInfo s.render(w, "dashboard", data) } func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("stacks", "Alkalmazások") data["Stacks"] = s.stackMgr.GetStacks() s.render(w, "stacks", data) } func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, r) return } logs, err := s.stackMgr.GetLogs(name, 200) if err != nil { logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) } // Raw mode: return plain text for AJAX polling if r.URL.Query().Get("raw") == "1" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, logs) return } data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") data["Stack"] = stack data["Logs"] = logs s.render(w, "logs", data) } func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) { meta, appCfg, err := s.stackMgr.GetDeployFields(name) if err != nil { http.NotFound(w, nil) return } stack, _ := s.stackMgr.GetStack(name) alreadyDeployed := appCfg != nil && appCfg.Deployed data := s.baseData("deploy", meta.DisplayName+" — Telepítés") data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = alreadyDeployed data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) data["UserFields"] = meta.UserFacingFields() data["AutoFields"] = meta.AutoGeneratedFields() // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { memInfo := map[string]interface{}{"Available": false} totalMB, memErr := system.GetTotalMemoryMB() if memErr == nil { reservedMB := s.cfg.System.ReservedMemoryMB usableMB := totalMB - reservedMB committedReqMB, committedLimitMB := s.stackMgr.CommittedMemory() newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest) newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit) afterReqMB := committedReqMB + newReqMB afterLimitMB := committedLimitMB + newLimitMB percent := 0 if usableMB > 0 { percent = afterReqMB * 100 / usableMB } committedPercent := 0 if usableMB > 0 { committedPercent = committedReqMB * 100 / usableMB } memInfo["Available"] = true memInfo["TotalMB"] = totalMB memInfo["ReservedMB"] = reservedMB memInfo["UsableMB"] = usableMB memInfo["CommittedMB"] = committedReqMB memInfo["NewRequestMB"] = newReqMB memInfo["AfterMB"] = afterReqMB memInfo["Percent"] = percent memInfo["CommittedPercent"] = committedPercent memInfo["Blocked"] = newReqMB > 0 && afterReqMB > usableMB memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB } data["MemoryInfo"] = memInfo } s.render(w, "deploy", data) } // 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) } func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug string) { var found *stacks.Stack for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { found = &stack break } } if found == nil { http.NotFound(w, nil) return } // Load current optional config values from app.yaml currentValues := make(map[string]string) if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { for k, v := range appCfg.Env { currentValues[k] = v } } data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo data["OptionalConfig"] = found.Meta.OptionalConfig data["CurrentValues"] = currentValues data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() s.render(w, "app_info", data) } func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) { data := map[string]interface{}{ "Title": "Bejelentkezés", "CustomerName": s.cfg.Customer.Name, "Error": errorMsg, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil { s.logger.Printf("[ERROR] Template error (login): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } } 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) } }