package main import ( "context" "encoding/json" "errors" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) // Admin user management — port of main.py's /admin + /api-admin/users routes. // All require an admin session (requireAdmin). Writes only succeed in write // (cutover) mode; on the read-only parallel instance the txn is rejected. // GET /admin/users — serve the admin page (admin only). func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } s.serveStaticFile(w, r, "admin.html") } // GET /api-admin/users — list users (admin only). func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() rows, err := s.pool.Query(ctx, "SELECT id, username, is_admin, created_at FROM users ORDER BY id") if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "db error"}) return } defer rows.Close() users := []map[string]any{} for rows.Next() { var id int var username string var isAdmin bool var createdAt time.Time if rows.Scan(&id, &username, &isAdmin, &createdAt) != nil { continue } users = append(users, map[string]any{ "id": id, "username": username, "is_admin": isAdmin, "created_at": createdAt.UTC().Format("2006-01-02T15:04:05.999999"), }) } writeJSON(w, http.StatusOK, map[string]any{"users": users}) } // POST /api-admin/users — create a user (admin only). func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } var body struct { Username string `json:"username"` Password string `json:"password"` IsAdmin bool `json:"is_admin"` } _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) username := strings.TrimSpace(body.Username) if username == "" || body.Password == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"}) return } if len(body.Password) < 4 { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"}) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var existing int if s.pool.QueryRow(ctx, "SELECT id FROM users WHERE LOWER(username) = $1", strings.ToLower(username)).Scan(&existing) == nil { writeJSON(w, http.StatusConflict, map[string]any{"detail": "Username already exists"}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(body.Password), 12) if _, err := s.pool.Exec(ctx, "INSERT INTO users (username, password_hash, is_admin) VALUES ($1,$2,$3)", username, string(hash), body.IsAdmin); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Failed to create user"}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": username}) } // PATCH /api-admin/users/{user_id} — password reset / admin toggle (admin only). func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } id, _ := strconv.Atoi(r.PathValue("user_id")) var body map[string]any _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var exists int if errors.Is(s.pool.QueryRow(ctx, "SELECT id FROM users WHERE id = $1", id).Scan(&exists), pgx.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"}) return } if pw, ok := body["password"].(string); ok { if len(pw) < 4 { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(pw), 12) if _, err := s.pool.Exec(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2", string(hash), id); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"}) return } } if a, ok := body["is_admin"]; ok { isAdmin, _ := a.(bool) if _, err := s.pool.Exec(ctx, "UPDATE users SET is_admin = $1 WHERE id = $2", isAdmin, id); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"}) return } } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // DELETE /api-admin/users/{user_id} — delete a user (admin only, not yourself). func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } id, _ := strconv.Atoi(r.PathValue("user_id")) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var username string if errors.Is(s.pool.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&username), pgx.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"}) return } if cur := currentUser(r); cur != nil && strings.EqualFold(username, cur.Username) { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Cannot delete yourself"}) return } if _, err := s.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "delete failed"}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }