341 lines
10 KiB
Markdown
341 lines
10 KiB
Markdown
# TASK.md — Code Review Bugfixes (v0.6.1)
|
||
|
||
## Overview
|
||
|
||
Fix bugs and logic issues identified during the v0.6.0 code review. All changes are in the `controller/` subtree.
|
||
No new features — only correctness, safety, and quality fixes.
|
||
|
||
After all fixes: bump version to **v0.6.1**, commit, build, push, deploy, verify.
|
||
|
||
---
|
||
|
||
## Fix 1: `http.NotFound(w, nil)` — pass request, not nil
|
||
|
||
**Files:** `internal/web/handlers.go`
|
||
|
||
**Problem:** Two handlers discard the `*http.Request` parameter as `_`, then call `http.NotFound(w, nil)`. While Go's current stdlib doesn't dereference the request in `NotFound`, this is incorrect and will break if middleware wraps it.
|
||
|
||
**Changes:**
|
||
|
||
In `deployHandler`, change signature and call:
|
||
```go
|
||
// BEFORE:
|
||
func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) {
|
||
...
|
||
if err != nil {
|
||
http.NotFound(w, nil)
|
||
|
||
// AFTER:
|
||
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||
...
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
```
|
||
|
||
In `appDetailHandler`, same fix:
|
||
```go
|
||
// BEFORE:
|
||
func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug string) {
|
||
...
|
||
if found == nil {
|
||
http.NotFound(w, nil)
|
||
|
||
// AFTER:
|
||
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||
...
|
||
if found == nil {
|
||
http.NotFound(w, r)
|
||
```
|
||
|
||
**Verify:** Grep for `NotFound(w, nil)` — should return 0 results after fix.
|
||
|
||
---
|
||
|
||
## Fix 2: Dashboard running/stopped counts don't match displayed stacks
|
||
|
||
**File:** `internal/web/handlers.go`, `dashboardHandler`
|
||
|
||
**Problem:** The `running`/`stopped` stat counters iterate over ALL stacks (including non-deployed ones), but the dashboard only displays deployed + protected stacks. The numbers don't match what the user sees.
|
||
|
||
**Fix:** Compute the counts from the same filtered set (`deployedStacks`), not from `stackList`. Move the filter loop first, then count from the filtered result.
|
||
|
||
```go
|
||
func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
||
stackList := s.stackMgr.GetStacks()
|
||
|
||
// Filter to deployed + protected stacks first
|
||
var deployedStacks []stacks.Stack
|
||
for _, st := range stackList {
|
||
if st.Deployed || st.Protected {
|
||
deployedStacks = append(deployedStacks, st)
|
||
}
|
||
}
|
||
|
||
// 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++
|
||
}
|
||
}
|
||
|
||
// ... rest unchanged, but use deployedStacks for display ...
|
||
data["Stacks"] = deployedStacks
|
||
data["RunningCount"] = running
|
||
data["StoppedCount"] = stopped
|
||
data["TotalCount"] = len(stackList) // keep this as total catalog size
|
||
```
|
||
|
||
**Verify:** Deploy, open dashboard. Count of green + red badges on cards should match the stat numbers.
|
||
|
||
---
|
||
|
||
## Fix 3: `Secure: true` cookie blocks HTTP login
|
||
|
||
**File:** `internal/web/auth.go`, `handleLogin`
|
||
|
||
**Problem:** The session cookie has `Secure: true` hardcoded. When accessing via plain HTTP (e.g., `http://192.168.0.162:8080` during local setup), the browser silently refuses to send the cookie back, making login impossible with no visible error.
|
||
|
||
**Fix:** Set `Secure` dynamically based on the incoming request:
|
||
|
||
```go
|
||
// BEFORE:
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: sessionCookieName,
|
||
Value: token,
|
||
Path: "/",
|
||
MaxAge: int(sessionMaxAge.Seconds()),
|
||
HttpOnly: true,
|
||
SameSite: http.SameSiteStrictMode,
|
||
Secure: true,
|
||
})
|
||
|
||
// AFTER:
|
||
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.SameSiteLaxMode, // Lax needed: Strict can break redirects through CF tunnel
|
||
Secure: isSecure,
|
||
})
|
||
```
|
||
|
||
Note: Also change `SameSiteStrictMode` → `SameSiteLaxMode`. Strict mode can cause issues when users arrive via Cloudflare Tunnel redirects (the cookie won't be sent on the first navigation from an external link).
|
||
|
||
**Verify:** Access `http://192.168.0.162:8080` in browser, log in — should work. Also verify HTTPS login still works via `https://vezerlo.demo-felhom.eu`.
|
||
|
||
---
|
||
|
||
## Fix 4: Remove misleading `subtle.ConstantTimeCompare` in session check
|
||
|
||
**File:** `internal/web/auth.go`, `isValidSession`
|
||
|
||
**Problem:** The map lookup `s.sessions[token]` already reveals the token via timing. The subsequent `ConstantTimeCompare` compares the token to itself (it was just fetched by that key), so it always returns 1 and adds no security. It's misleading to keep it.
|
||
|
||
**Fix:** Simplify:
|
||
|
||
```go
|
||
// BEFORE:
|
||
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
|
||
}
|
||
|
||
// AFTER:
|
||
func (s *Server) isValidSession(token string) bool {
|
||
s.sessionsMu.RLock()
|
||
defer s.sessionsMu.RUnlock()
|
||
sess, ok := s.sessions[token]
|
||
return ok && time.Now().Before(sess.expiresAt)
|
||
}
|
||
```
|
||
|
||
Also: the `token` field in the `session` struct is now unused (it duplicates the map key). Remove it:
|
||
|
||
```go
|
||
// BEFORE:
|
||
type session struct {
|
||
token string
|
||
expiresAt time.Time
|
||
}
|
||
|
||
// AFTER:
|
||
type session struct {
|
||
expiresAt time.Time
|
||
}
|
||
```
|
||
|
||
And update `createSession`:
|
||
```go
|
||
// BEFORE:
|
||
s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)}
|
||
|
||
// AFTER:
|
||
s.sessions[token] = &session{expiresAt: time.Now().Add(sessionMaxAge)}
|
||
```
|
||
|
||
After these changes, remove the `"crypto/subtle"` import if no longer used.
|
||
|
||
**Verify:** Log in, navigate around — session should work. Log out — should redirect to login.
|
||
|
||
---
|
||
|
||
## Fix 5: `cleanupSessions` goroutine leak
|
||
|
||
**File:** `internal/web/auth.go`
|
||
|
||
**Problem:** `time.Tick()` creates a ticker that can never be GC'd. The goroutine runs forever, even during shutdown.
|
||
|
||
**Fix:** This one is lower priority since the controller runs as a long-lived process, but the fix is simple. Since we don't currently pass a context to `NewServer`, use a `done` channel on the server:
|
||
|
||
Add a `done` channel to the Server struct:
|
||
```go
|
||
type Server struct {
|
||
// ... existing fields ...
|
||
done chan struct{}
|
||
}
|
||
```
|
||
|
||
Initialize it in `NewServer`:
|
||
```go
|
||
func NewServer(...) *Server {
|
||
s := &Server{
|
||
// ... existing ...
|
||
done: make(chan struct{}),
|
||
}
|
||
s.loadTemplates()
|
||
go s.cleanupSessions()
|
||
return s
|
||
}
|
||
```
|
||
|
||
Rewrite `cleanupSessions`:
|
||
```go
|
||
func (s *Server) cleanupSessions() {
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Add a `Close` method (called from main during shutdown, optional for now):
|
||
```go
|
||
func (s *Server) Close() {
|
||
close(s.done)
|
||
}
|
||
```
|
||
|
||
**Verify:** Build succeeds, controller starts without errors.
|
||
|
||
---
|
||
|
||
## Fix 6: Add `http.MaxBytesReader` to API POST endpoints
|
||
|
||
**File:** `internal/api/router.go`
|
||
|
||
**Problem:** `json.NewDecoder(req.Body).Decode(&body)` has no size limit. A malicious or accidental large POST could exhaust memory.
|
||
|
||
**Fix:** Add a helper and use it in all handlers that decode JSON bodies (`deployStack`, `updateOptionalConfig`, `deleteStack`):
|
||
|
||
Add helper at the bottom of `router.go`:
|
||
```go
|
||
// 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
|
||
}
|
||
```
|
||
|
||
Then at the start of each handler that reads the body:
|
||
```go
|
||
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
||
limitBody(w, req)
|
||
// ... existing json decode ...
|
||
}
|
||
|
||
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
|
||
limitBody(w, req)
|
||
// ...
|
||
}
|
||
|
||
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||
limitBody(w, req)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**Verify:** Build succeeds. Normal deploy/config/delete still works (payloads are tiny).
|
||
|
||
---
|
||
|
||
## Fix 7: Cache `time.LoadLocation` in template funcmap
|
||
|
||
**File:** `internal/web/funcmap.go`
|
||
|
||
**Problem:** At least 5 template functions call `time.LoadLocation("Europe/Budapest")` on every render. While Go caches internally, it still acquires a mutex each time.
|
||
|
||
**Fix:** Load once at the top of `templateFuncMap` and capture in the closures:
|
||
|
||
```go
|
||
func (s *Server) templateFuncMap() template.FuncMap {
|
||
loc, err := time.LoadLocation("Europe/Budapest")
|
||
if err != nil {
|
||
loc = time.UTC
|
||
}
|
||
|
||
return template.FuncMap{
|
||
// ... in every function that currently calls time.LoadLocation,
|
||
// replace with the captured `loc` variable.
|
||
// Remove the per-function `loc, _ := time.LoadLocation(...)` lines.
|
||
// Example:
|
||
"timeAgo": func(t time.Time) string {
|
||
if t.IsZero() { return "–" }
|
||
now := time.Now().In(loc)
|
||
d := now.Sub(t.In(loc))
|
||
// ... rest unchanged ...
|
||
},
|
||
// Apply same pattern to: fmtTime, fmtTimeShort, nextRunLabel, nextPruneLabel
|
||
}
|
||
}
|
||
```
|
||
|
||
There are 5 functions that need this change: `timeAgo`, `fmtTime`, `fmtTimeShort`, `nextRunLabel`, `nextPruneLabel`.
|
||
|
||
**Verify:** Build succeeds. Dashboard/backup page timestamps still display correctly in Budapest time.
|
||
|
||
---
|
||
|
||
## Post-fix checklist
|
||
|
||
1. `grep -rn 'NotFound(w, nil)' internal/` → 0 results
|
||
2. `grep -rn 'subtle.ConstantTimeCompare' internal/` → 0 results (unless used elsewhere)
|
||
3. `grep -rn 'time.Tick(' internal/` → 0 results
|
||
4. `grep -rn 'Secure:.*true' internal/web/auth.go` → 0 results (now dynamic)
|
||
5. Build: `go build ./cmd/controller/` succeeds with no errors
|
||
6. `go vet ./...` passes
|
||
7. Version bump in build to v0.6.1
|
||
8. Commit, push, build, deploy, verify on demo-felhom.eu |