package main import ( "context" "encoding/json" "net/http" "os" "path" "path/filepath" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // Website-serving layer: static frontend + login/logout, porting main.py so the // unchanged frontend loads on the Go tracker. Cookie issuing/verifying is in // auth.go; this file is the handlers + the static file server. // A fixed bcrypt hash used to keep the no-such-user path constant-time, matching // Python's _DUMMY_HASH. (Hash of an arbitrary constant; never matches input.) var dummyBcryptHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEeO3Jj6Q1jK7Z1qkq9b2yY6m4eW7N0pZ2K") type loginLimiter struct { mu sync.Mutex last map[string]time.Time } func newLoginLimiter() *loginLimiter { return &loginLimiter{last: map[string]time.Time{}} } // allow returns false if this IP attempted within the 5s cooldown (main.py). func (l *loginLimiter) allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() if t, ok := l.last[ip]; ok && now.Sub(t) < 5*time.Second { return false } l.last[ip] = now return true } // GET /login — serve the login page (main.py:login_page). func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) { s.serveStaticFile(w, r, "login.html") } // POST /login — authenticate and set the session cookie (main.py:login). func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ip = strings.TrimSpace(strings.Split(xff, ",")[0]) } if !s.loginLimiter.allow(ip) { writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."}) return } var body struct { Username string `json:"username"` Password string `json:"password"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid request body"}) return } username := strings.ToLower(strings.TrimSpace(body.Username)) if username == "" || body.Password == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"}) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var dbUser, hash string var isAdmin bool err := s.pool.QueryRow(ctx, "SELECT username, password_hash, is_admin FROM users WHERE LOWER(username) = $1", username, ).Scan(&dbUser, &hash, &isAdmin) // Constant-time: always run bcrypt, even when the user doesn't exist. pwOK := false if err == nil { pwOK = bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) == nil } else { _ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(body.Password)) } if !pwOK { writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid username or password"}) return } token := issueSessionCookie(s.secretKey, sessionUser{Username: dbUser, IsAdmin: isAdmin}) http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", MaxAge: sessionMaxAge, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": dbUser, "is_admin": isAdmin}) } // GET /logout — clear the cookie and redirect to /login (main.py:logout). func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/login", http.StatusFound) } // GET /icons/{filename} — serve an icon file (main.py:serve_icon). func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) { name := r.PathValue("filename") if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { http.NotFound(w, r) return } s.serveStaticFile(w, r, filepath.Join("icons", name)) } // handleStatic is the catch-all GET handler: serves files from staticDir, falls // back to index.html for SPA routes (React client-side routing). Registered last // so the specific API routes take precedence. func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { upath := path.Clean("/" + r.URL.Path) full := filepath.Join(s.staticDir, filepath.FromSlash(upath)) // Guard against path traversal escaping staticDir. if rel, err := filepath.Rel(s.staticDir, full); err != nil || strings.HasPrefix(rel, "..") { http.NotFound(w, r) return } if info, err := os.Stat(full); err == nil { if info.IsDir() { if idx := filepath.Join(full, "index.html"); fileExists(idx) { http.ServeFile(w, r, idx) return } } else { http.ServeFile(w, r, full) return } } // SPA fallback — serve the app shell for unknown (client-routed) paths. http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html")) } func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, rel string) { full := filepath.Join(s.staticDir, filepath.FromSlash(rel)) if !fileExists(full) { http.Error(w, "Not found", http.StatusNotFound) return } http.ServeFile(w, r, full) } func fileExists(p string) bool { info, err := os.Stat(p) return err == nil && !info.IsDir() } // runIssueCookieCLI prints a session token for cross-checking itsdangerous // cookie interop with the Python service. func runIssueCookieCLI() { if len(os.Args) < 5 { os.Stderr.WriteString("usage: tracker-go issue-cookie \n") os.Exit(2) } os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"})) }