package web import ( "bytes" "context" "encoding/json" "errors" "fmt" "html/template" "log/slog" "net/http" "strconv" "strings" "time" "go-htmx-chat/internal/chat" "go-htmx-chat/internal/store" ) type Server struct { store *store.Store hub *chat.Hub templates *template.Template logger *slog.Logger auth AuthConfig oidc oidcDiscovery } func NewServer(st *store.Store, hub *chat.Hub, logger *slog.Logger, auth AuthConfig) (*Server, error) { funcs := template.FuncMap{ "formatTime": func(t time.Time) string { return t.Format("15:04") }, "initial": func(s string) string { s = strings.TrimSpace(s) if s == "" { return "?" } for _, r := range s { return strings.ToUpper(string(r)) } return "?" }, } tmpl, err := template.New("base").Funcs(funcs).ParseGlob("templates/**/*.html") if err != nil { return nil, err } tmpl, err = tmpl.ParseGlob("templates/*.html") if err != nil { return nil, err } srv := &Server{store: st, hub: hub, templates: tmpl, logger: logger, auth: auth} if err := srv.discoverOIDC(); err != nil { return nil, err } return srv, nil } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) mux.HandleFunc("GET /", s.handleHome) mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("GET /auth/login", s.handleOIDCLogin) mux.HandleFunc("GET /auth/callback", s.handleOIDCCallback) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /rooms", s.handleRooms) mux.HandleFunc("POST /rooms", s.handleCreateRoom) mux.HandleFunc("GET /rooms/{id}", s.handleRoom) mux.HandleFunc("POST /rooms/{id}/messages", s.handlePostMessage) mux.HandleFunc("GET /rooms/{id}/events", s.handleEvents) return s.recover(nextSecurityHeaders(mux)) } func nextSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") next.ServeHTTP(w, r) }) } func (s *Server) recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { s.logger.Error("panic", "error", rec) http.Error(w, "internal server error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { username := s.currentUser(r) if username == "" { s.render(w, r, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode}) return } http.Redirect(w, r, "/rooms", http.StatusSeeOther) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if s.auth.Mode == AuthModeOIDC { http.Redirect(w, r, "/auth/login", http.StatusSeeOther) return } username := strings.TrimSpace(r.FormValue("username")) if len(username) < 2 || len(username) > 24 { s.renderStatus(w, r, http.StatusBadRequest, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode, "Error": "Bitte nutze einen Namen mit 2 bis 24 Zeichen."}) return } cookie := &http.Cookie{ Name: "chat_user", Value: username, Path: "/", MaxAge: 60 * 60 * 24 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, } http.SetCookie(w, cookie) w.Header().Set("HX-Redirect", "/rooms") http.Redirect(w, r, "/rooms", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: "chat_user", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) clearCookie(w, "chat_session", "/") http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleRooms(w http.ResponseWriter, r *http.Request) { username := s.requireUser(w, r) if username == "" { return } rooms, err := s.store.Rooms() if err != nil { http.Error(w, err.Error(), 500) return } s.render(w, r, "rooms.html", map[string]any{"Title": "Räume", "Username": username, "Rooms": rooms}) } func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { username := s.requireUser(w, r) if username == "" { return } name := strings.TrimSpace(r.FormValue("name")) description := strings.TrimSpace(r.FormValue("description")) if len(name) < 2 || len(name) > 40 { http.Error(w, "Raumname muss 2 bis 40 Zeichen haben.", http.StatusBadRequest) return } room, err := s.store.CreateRoom(name, description) if err != nil { http.Error(w, "Raum konnte nicht erstellt werden. Existiert er schon?", http.StatusBadRequest) return } w.Header().Set("HX-Redirect", fmt.Sprintf("/rooms/%d", room.ID)) http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) } func (s *Server) handleRoom(w http.ResponseWriter, r *http.Request) { username := s.requireUser(w, r) if username == "" { return } roomID, ok := parseID(w, r) if !ok { return } room, err := s.store.Room(roomID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { http.Error(w, err.Error(), 500) return } messages, err := s.store.RecentMessages(roomID, 100) if err != nil { http.Error(w, err.Error(), 500) return } s.render(w, r, "room.html", map[string]any{"Title": room.Name, "Username": username, "Room": room, "Messages": messages}) } func (s *Server) handlePostMessage(w http.ResponseWriter, r *http.Request) { username := s.requireUser(w, r) if username == "" { return } roomID, ok := parseID(w, r) if !ok { return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { w.WriteHeader(http.StatusNoContent) return } if len(body) > 2000 { http.Error(w, "Nachricht ist zu lang.", http.StatusBadRequest) return } msg, err := s.store.AddMessage(roomID, username, body) if err != nil { http.Error(w, err.Error(), 500) return } s.hub.Publish(msg) // Clear the form without duplicating the message; the SSE stream appends it. w.Header().Set("HX-Trigger", "message-sent") w.WriteHeader(http.StatusNoContent) } func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { username := s.requireUser(w, r) if username == "" { return } roomID, ok := parseID(w, r) if !ok { return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") sub := s.hub.Subscribe(roomID) defer s.hub.Unsubscribe(roomID, sub) fmt.Fprint(w, ": connected\n\n") flusher.Flush() heartbeat := time.NewTicker(25 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): return case <-heartbeat.C: fmt.Fprint(w, ": heartbeat\n\n") flusher.Flush() case msg := <-sub: html, err := s.renderPartial(r.Context(), "partials/message.html", msg) if err != nil { s.logger.Error("render partial", "error", err) continue } payload := map[string]string{"html": html} b, _ := json.Marshal(payload) fmt.Fprintf(w, "event: message\ndata: %s\n\n", b) flusher.Flush() } } } func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { http.NotFound(w, r) return 0, false } return id, true } func (s *Server) currentUser(r *http.Request) string { if sess, ok := s.readSession(r); ok { return strings.TrimSpace(sess.Username) } if s.auth.Mode == AuthModeLocal { c, err := r.Cookie("chat_user") if err != nil { return "" } return strings.TrimSpace(c.Value) } return "" } func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) string { username := s.currentUser(r) if username == "" { http.Redirect(w, r, "/", http.StatusSeeOther) return "" } return username } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { s.renderStatus(w, r, http.StatusOK, name, data) } func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := s.templates.ExecuteTemplate(w, name, data); err != nil { s.logger.Error("template", "name", name, "error", err) } } func (s *Server) renderPartial(_ context.Context, name string, data any) (string, error) { var buf bytes.Buffer err := s.templates.ExecuteTemplate(&buf, name, data) return buf.String(), err }