v0.6.1: Code review bugfixes — 7 correctness/safety/quality fixes

- Fix http.NotFound(w, nil) → pass actual request in handlers
- Fix dashboard running/stopped counts to match displayed stacks
- Fix Secure cookie blocking HTTP login (dynamic based on request)
- Remove misleading subtle.ConstantTimeCompare in session check
- Fix cleanupSessions goroutine leak (proper ticker + done channel)
- Add http.MaxBytesReader (1MB) to API POST endpoints
- Cache time.LoadLocation("Europe/Budapest") in template funcmap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 14:40:13 +01:00
parent 104c97040c
commit ded0cbb842
6 changed files with 56 additions and 57 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ controller generates secrets, saves app.yaml, runs `docker compose up -d`, and t
with Traefik routing and health checks. The dashboard correctly shows real-time container states
including health substatus (starting → healthy → running).
Current version: **v0.6.0**
Current version: **v0.6.1**
### What works
- Dashboard with live container state (green/orange/yellow/red)
+8
View File
@@ -188,6 +188,7 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Deploy requested for stack: %s", name)
var body struct {
@@ -261,6 +262,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
}
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Optional config update requested for stack: %s", name)
var body struct {
@@ -306,6 +308,7 @@ func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name st
}
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Delete requested for stack: %s", name)
var body struct {
@@ -585,3 +588,8 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
log.Printf("[ERROR] Failed to write JSON response: %v", err)
}
}
// limitBody wraps the request body with a size limit (default 1MB).
func limitBody(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}
+24 -17
View File
@@ -2,7 +2,6 @@ package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
@@ -13,7 +12,6 @@ import (
)
type session struct {
token string
expiresAt time.Time
}
@@ -81,14 +79,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
token := s.createSession()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure,
})
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
@@ -111,7 +110,7 @@ func (s *Server) createSession() string {
token := hex.EncodeToString(b)
s.sessionsMu.Lock()
s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)}
s.sessions[token] = &session{expiresAt: time.Now().Add(sessionMaxAge)}
s.sessionsMu.Unlock()
return token
@@ -120,27 +119,35 @@ func (s *Server) createSession() string {
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
return ok && time.Now().Before(sess.expiresAt)
}
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)
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
s.sessionsMu.Unlock()
}
}
// Close signals the server to stop background goroutines.
func (s *Server) Close() {
close(s.done)
}
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
data := map[string]interface{}{
"Title": "Bejelentkezés",
+5 -20
View File
@@ -12,6 +12,11 @@ import (
// templateFuncMap returns the FuncMap used by all HTML templates.
func (s *Server) templateFuncMap() template.FuncMap {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
loc = time.UTC
}
return template.FuncMap{
"stateColor": func(state stacks.ContainerState) string {
switch state {
@@ -164,10 +169,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
now := time.Now().In(loc)
d := now.Sub(t.In(loc))
switch {
@@ -187,20 +188,12 @@ func (s *Server) templateFuncMap() template.FuncMap {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
return t.In(loc).Format("2006-01-02 15:04")
},
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
lt := t.In(loc)
now := time.Now().In(loc)
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
@@ -222,10 +215,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
lt := t.In(loc)
now := time.Now().In(loc)
timeStr := lt.Format("15:04")
@@ -250,10 +239,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
}
},
"nextPruneLabel": func(schedule string) string {
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
now := time.Now().In(loc)
var next time.Time
switch strings.ToLower(schedule) {
+16 -19
View File
@@ -22,21 +22,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
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++
}
}
// Filter to deployed + protected stacks only for dashboard display
// Filter to deployed + protected stacks first
var deployedStacks []stacks.Stack
for _, st := range stackList {
if st.Deployed || st.Protected {
@@ -44,6 +30,17 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
}
}
// Count from the DISPLAYED set only
running, stopped := 0, 0
for _, st := range deployedStacks {
switch st.State {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
running++
case stacks.StateStopped, stacks.StateExited:
stopped++
}
}
sysInfo := system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
@@ -99,10 +96,10 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
s.render(w, "logs", data)
}
func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) {
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
http.NotFound(w, nil)
http.NotFound(w, r)
return
}
@@ -160,7 +157,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name stri
s.render(w, "deploy", data)
}
func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug string) {
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
@@ -169,7 +166,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug s
}
}
if found == nil {
http.NotFound(w, nil)
http.NotFound(w, r)
return
}
+2
View File
@@ -29,6 +29,7 @@ type Server struct {
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, logger *log.Logger, version string) *Server {
@@ -41,6 +42,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
logger: logger,
version: version,
sessions: make(map[string]*session),
done: make(chan struct{}),
}
s.loadTemplates()
go s.cleanupSessions()