All checks were successful
release-tag / release-image (push) Successful in 1m55s
319 lines
8.5 KiB
Go
319 lines
8.5 KiB
Go
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
|
|
}
|