package web import ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "log/slog" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "track-gopher/db" "track-gopher/derby" "track-gopher/models" "track-gopher/web/templates" ) //go:embed static var content embed.FS // Server represents the web server for the derby clock type Server struct { router *chi.Mux clock *derby.DerbyClock events <-chan derby.Event clients map[chan string]bool clientsMux sync.Mutex port int server *http.Server shutdown chan struct{} logger *slog.Logger db *db.DB adminEvents chan string } // NewServer creates a new web server func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, logger *slog.Logger) (*Server, error) { // Initialize database database, err := db.New(dbPath) if err != nil { return nil, fmt.Errorf("failed to initialize database: %w", err) } // Create server s := &Server{ router: chi.NewRouter(), clock: clock, events: events, clients: make(map[chan string]bool), clientsMux: sync.Mutex{}, port: port, shutdown: make(chan struct{}), logger: logger, db: database, adminEvents: make(chan string, 10), } // Set up routes s.routes() // Start event forwarder go s.forwardEvents() return s, nil } // routes sets up the routes for the server func (s *Server) routes() { // Middleware s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) // Create a file server for static files staticFS, err := fs.Sub(content, "static") if err != nil { log.Fatal(err) } // Set up static file server with proper MIME types fileServer := http.FileServer(http.FS(staticFS)) s.router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { // Set correct MIME types based on file extension path := r.URL.Path if strings.HasSuffix(path, ".js") { w.Header().Set("Content-Type", "application/javascript") } else if strings.HasSuffix(path, ".css") { w.Header().Set("Content-Type", "text/css") } // The key fix: properly handle the path // Remove the /static prefix for the filesystem lookup pathWithoutPrefix := strings.TrimPrefix(r.URL.Path, "/static") // Create a new request with the modified path r2 := new(http.Request) *r2 = *r r2.URL = new(url.URL) *r2.URL = *r.URL r2.URL.Path = pathWithoutPrefix fileServer.ServeHTTP(w, r2) }) // API routes s.router.Route("/api", func(r chi.Router) { r.Post("/reset", s.handleReset()) r.Post("/force-end", s.handleForceEnd()) r.Get("/events", s.handleEvents()) }) s.router.Get("/admin", s.handleAdmin()) s.router.Get("/register", s.handleRegister()) s.router.Get("/register/form", s.handleRegisterForm()) s.router.Route("/api/groups", func(r chi.Router) { r.Post("/", s.handleCreateGroup()) r.Put("/{id}", s.handleUpdateGroup()) r.Delete("/{id}", s.handleDeleteGroup()) }) s.router.Route("/api/racers", func(r chi.Router) { r.Post("/", s.handleCreateRacer()) r.Put("/{id}", s.handleUpdateRacer()) r.Delete("/{id}", s.handleDeleteRacer()) }) // Add heats page route s.router.Get("/heats", s.handleHeats()) // Add heats API routes s.router.Route("/api/heats", func(r chi.Router) { r.Post("/generate", s.handleGenerateHeats()) r.Post("/save", s.handleSaveHeats()) }) // Main page s.router.Get("/", s.handleIndex()) // Add racers list route s.router.Get("/admin/racers/list", s.handleRacersList()) // Add admin events route s.router.Get("/api/admin-events", s.handleAdminEvents()) // Add validate car number route s.router.Get("/api/validate/car-number", s.handleValidateCarNumber()) } // Start starts the web server func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) s.server = &http.Server{ Addr: addr, Handler: s.router, } // Start server in a goroutine go func() { s.logger.Info("Web server starting", "port", s.port) if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Error("HTTP server error", "error", err) } }() return nil } // Stop gracefully shuts down the server func (s *Server) Stop() error { // Close database connection if s.db != nil { if err := s.db.Close(); err != nil { s.logger.Error("Error closing database", "error", err) } } // Create a context with timeout for shutdown ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.logger.Info("Shutting down web server") // Shutdown the HTTP server if s.server != nil { return s.server.Shutdown(ctx) } return nil } // forwardEvents forwards derby events to SSE clients func (s *Server) forwardEvents() { for { select { case event, ok := <-s.events: if !ok { return } // Process the event and send to clients s.broadcastEvent(event) case <-s.shutdown: return } } } // broadcastEvent sends an event to all connected clients func (s *Server) broadcastEvent(event derby.Event) { var message string switch event.Type { case derby.EventRaceStart: s.logger.Info("Broadcasting race start event") statusMsg := struct { Status string `json:"status"` }{ Status: "running", } statusJSON, _ := json.Marshal(statusMsg) message = fmt.Sprintf("event: status\ndata: %s", statusJSON) case derby.EventLaneFinish: s.logger.Info("Broadcasting lane finish event", "lane", event.Result.Lane, "time", event.Result.Time, "place", event.Result.FinishPlace) // Create a message for lane finish laneData := struct { Lane int `json:"lane"` Time float64 `json:"time"` Place int `json:"place"` }{ Lane: event.Result.Lane, Time: event.Result.Time, Place: event.Result.FinishPlace, } laneJSON, _ := json.Marshal(laneData) message = fmt.Sprintf("event: lane-finish\ndata: %s", laneJSON) case derby.EventRaceComplete: s.logger.Info("Broadcasting race complete event") statusMsg := struct { Status string `json:"status"` }{ Status: "finished", } statusJSON, _ := json.Marshal(statusMsg) message = fmt.Sprintf("event: status\ndata: %s", statusJSON) } if message == "" { return } // Send to all clients s.clientsMux.Lock() clientCount := len(s.clients) sentCount := 0 for clientChan := range s.clients { select { case clientChan <- message: sentCount++ default: s.logger.Warn("Client channel is full, event not sent") } } s.logger.Info("Event broadcast complete", "sentCount", sentCount, "totalClients", clientCount, "eventType", event.Type) s.clientsMux.Unlock() } // handleIndex handles the index page func (s *Server) handleIndex() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { templates.Index().Render(r.Context(), w) } } // handleReset handles the reset API endpoint func (s *Server) handleReset() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := s.clock.Reset(); err != nil { http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "reset"}`)) } } // handleForceEnd handles the force end API endpoint func (s *Server) handleForceEnd() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := s.clock.ForceEnd(); err != nil { http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "forced"}`)) } } // handleStatus handles the status API endpoint func (s *Server) handleStatus() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { status := s.clock.Status() var statusStr string switch status { case derby.StatusIdle: statusStr = "idle" case derby.StatusRunning: statusStr = "running" case derby.StatusFinished: statusStr = "finished" } w.Header().Set("Content-Type", "application/json") w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr))) } } // handleEvents handles SSE events func (s *Server) handleEvents() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Set headers for SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") // Debug message to confirm connection fmt.Fprintf(w, "event: debug\ndata: {\"message\":\"SSE connection established\"}\n\n") // Flush headers to ensure they're sent to the client if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } else { http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) return } // Create a channel for this client clientChan := make(chan string, 10) // Add client to map with mutex protection s.clientsMux.Lock() s.clients[clientChan] = true clientCount := len(s.clients) s.clientsMux.Unlock() s.logger.Info("New client connected", "clientIP", r.RemoteAddr, "totalClients", clientCount) // Remove client when connection is closed defer func() { s.clientsMux.Lock() delete(s.clients, clientChan) remainingClients := len(s.clients) s.clientsMux.Unlock() close(clientChan) s.logger.Info("Client disconnected", "clientIP", r.RemoteAddr, "remainingClients", remainingClients) }() // Keep connection open and send events as they arrive for { select { case msg, ok := <-clientChan: if !ok { return } fmt.Fprintf(w, "%s\n\n", msg) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } case <-r.Context().Done(): return } } } } // handleAdmin renders the admin page func (s *Server) handleAdmin() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Get groups and racers from database groups, err := s.db.GetGroups() if err != nil { s.logger.Error("Failed to get groups", "error", err) http.Error(w, "Failed to get groups", http.StatusInternalServerError) return } racers, err := s.db.GetRacers() if err != nil { s.logger.Error("Failed to get racers", "error", err) http.Error(w, "Failed to get racers", http.StatusInternalServerError) return } // Render template component := templates.Admin(groups, racers) if err := component.Render(r.Context(), w); err != nil { s.logger.Error("Failed to render admin template", "error", err) http.Error(w, "Failed to render page", http.StatusInternalServerError) } } } // handleRegister renders the racer registration page func (s *Server) handleRegister() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Get groups from database groups, err := s.db.GetGroups() if err != nil { s.logger.Error("Failed to get groups", "error", err) http.Error(w, "Failed to get groups", http.StatusInternalServerError) return } // Render template component := templates.Register(groups) if err := component.Render(r.Context(), w); err != nil { s.logger.Error("Failed to render register template", "error", err) http.Error(w, "Failed to render page", http.StatusInternalServerError) } } } // handleRegisterForm returns just the registration form component func (s *Server) handleRegisterForm() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Get groups groups, err := s.db.GetGroups() if err != nil { s.logger.Error("Failed to get groups", "error", err) http.Error(w, "Failed to get groups", http.StatusInternalServerError) return } // Render form with isAdmin=true component := templates.RegisterForm(groups, true) if err := component.Render(r.Context(), w); err != nil { s.logger.Error("Failed to render form", "error", err) http.Error(w, "Failed to render form", http.StatusInternalServerError) } } } // API handlers for groups // handleCreateGroup creates a new group func (s *Server) handleCreateGroup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Parse form if err := r.ParseForm(); err != nil { s.logger.Error("Failed to parse form", "error", err) http.Error(w, "Invalid form data", http.StatusBadRequest) return } // Get form values name := r.FormValue("name") description := r.FormValue("description") // Validate if name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } // Create group id, err := s.db.CreateGroup(name, description) if err != nil { s.logger.Error("Failed to create group", "error", err) http.Error(w, "Failed to create group", http.StatusInternalServerError) return } // Return success w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, `{"id":%d}`, id) } } // handleUpdateGroup updates a group func (s *Server) handleUpdateGroup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Get ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } // Parse form if err := r.ParseForm(); err != nil { s.logger.Error("Failed to parse form", "error", err) http.Error(w, "Invalid form data", http.StatusBadRequest) return } // Get form values name := r.FormValue("name") description := r.FormValue("description") // Validate if name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } // Update group if err := s.db.UpdateGroup(id, name, description); err != nil { s.logger.Error("Failed to update group", "error", err) http.Error(w, "Failed to update group", http.StatusInternalServerError) return } // Return success w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"id":%d}`, id) } } // handleDeleteGroup deletes a group func (s *Server) handleDeleteGroup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Get ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } // Delete group if err := s.db.DeleteGroup(id); err != nil { s.logger.Error("Failed to delete group", "error", err) http.Error(w, "Failed to delete group", http.StatusInternalServerError) return } // Return success w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"success":true}`) } } // API handlers for racers // handleCreateRacer creates a new racer func (s *Server) handleCreateRacer() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Parse form if err := r.ParseForm(); err != nil { s.logger.Error("Failed to parse form", "error", err) http.Error(w, "Invalid form data", http.StatusBadRequest) return } // Get form values firstName := r.FormValue("first_name") lastName := r.FormValue("last_name") carNumber := r.FormValue("car_number") carWeightStr := r.FormValue("car_weight") groupIDStr := r.FormValue("group_id") // Validate if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" { http.Error(w, "All fields are required", http.StatusBadRequest) return } // Parse numeric values carWeight, err := strconv.ParseFloat(carWeightStr, 64) if err != nil { http.Error(w, "Invalid car weight", http.StatusBadRequest) return } groupID, err := strconv.ParseInt(groupIDStr, 10, 64) if err != nil { http.Error(w, "Invalid group ID", http.StatusBadRequest) return } // Create racer id, err := s.db.CreateRacer(firstName, lastName, carNumber, carWeight, groupID) if err != nil { s.logger.Error("Failed to create racer", "error", err) http.Error(w, "Failed to create racer", http.StatusInternalServerError) return } s.logger.Info("Racer created", "id", id) // Broadcast event to admin page select { case s.adminEvents <- "racer-added": // Event sent default: // Channel full, non-blocking } // Return success message w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
Heats saved successfully!