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" "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.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.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.StateStopped, stacks.StateExited: return "○" case stacks.StateRestarting: return "◐" default: return "◌" } }, "stateStr": func(state stacks.ContainerState) string { return string(state) }, "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) }, } 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 strings.HasPrefix(path, "/static/assets/"): // Serve baked-in app assets (logos, screenshots) 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++ } } data := s.baseData("dashboard", "Vezérlőpult") data["Stacks"] = stackList data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) 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, _ *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, nil) return } logs, err := s.stackMgr.GetLogs(name, 200) if err != nil { logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) } 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) data := s.baseData("deploy", meta.DisplayName+" — Telepítés") data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = appCfg != nil && appCfg.Deployed 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() s.render(w, "deploy", data) } // serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/ // These are copied into the container at build time. const assetsDir = "/usr/share/felhom/assets" func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) { // Sanitize: prevent directory traversal 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) } // appDetailHandler serves a local app detail page (description, screenshots, FAQ). // TODO: Phase 1.5 — for now, redirect to the stacks page. // Future: render a dedicated app page template with baked-in content. func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { // Find the stack by slug for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { // For now, redirect to deploy page (if not deployed) or stacks page if !stack.Deployed { http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound) } else { http.Redirect(w, r, "/stacks", http.StatusFound) } return } } http.NotFound(w, r) } 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) } }