init
All checks were successful
release-tag / release-image (push) Successful in 1m55s

This commit is contained in:
2026-05-14 13:38:41 +02:00
parent 5d1ced594d
commit be7bd79fc7
16 changed files with 1434 additions and 1 deletions

54
internal/chat/hub.go Normal file
View File

@@ -0,0 +1,54 @@
package chat
import (
"sync"
"go-htmx-chat/internal/store"
)
type Subscriber chan store.Message
type Hub struct {
mu sync.RWMutex
rooms map[int64]map[Subscriber]struct{}
}
func NewHub() *Hub {
return &Hub{rooms: make(map[int64]map[Subscriber]struct{})}
}
func (h *Hub) Subscribe(roomID int64) Subscriber {
ch := make(Subscriber, 16)
h.mu.Lock()
if h.rooms[roomID] == nil {
h.rooms[roomID] = make(map[Subscriber]struct{})
}
h.rooms[roomID][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(roomID int64, ch Subscriber) {
h.mu.Lock()
if subs := h.rooms[roomID]; subs != nil {
delete(subs, ch)
close(ch)
if len(subs) == 0 {
delete(h.rooms, roomID)
}
}
h.mu.Unlock()
}
func (h *Hub) Publish(msg store.Message) {
h.mu.RLock()
subs := h.rooms[msg.RoomID]
for sub := range subs {
select {
case sub <- msg:
default:
// Drop for slow clients. They will still see history after refresh.
}
}
h.mu.RUnlock()
}

187
internal/store/store.go Normal file
View File

@@ -0,0 +1,187 @@
package store
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
var ErrNotFound = errors.New("not found")
type Room struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
type Message struct {
ID int64 `json:"id"`
RoomID int64 `json:"room_id"`
Username string `json:"username"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
mu sync.RWMutex
path string
nextRoomID int64
nextMessageID int64
rooms []Room
messages []Message
}
type diskData struct {
NextRoomID int64 `json:"next_room_id"`
NextMessageID int64 `json:"next_message_id"`
Rooms []Room `json:"rooms"`
Messages []Message `json:"messages"`
}
func Open(path string) (*Store, error) {
s := &Store{path: path, nextRoomID: 1, nextMessageID: 1}
if err := s.load(); err != nil {
return nil, err
}
if len(s.rooms) == 0 {
now := time.Now().UTC()
s.rooms = []Room{
{ID: s.nextRoomID, Name: "Lobby", Description: "Allgemeiner Chat für alle", CreatedAt: now},
{ID: s.nextRoomID + 1, Name: "Go", Description: "Golang, Backend und Deployment", CreatedAt: now},
{ID: s.nextRoomID + 2, Name: "Random", Description: "Alles, was sonst nirgends passt", CreatedAt: now},
}
s.nextRoomID += 3
if err := s.saveLocked(); err != nil {
return nil, err
}
}
return s, nil
}
func (s *Store) Close() error { return nil }
func (s *Store) load() error {
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var d diskData
if err := json.Unmarshal(b, &d); err != nil {
return err
}
s.nextRoomID = maxInt64(d.NextRoomID, 1)
s.nextMessageID = maxInt64(d.NextMessageID, 1)
s.rooms = d.Rooms
s.messages = d.Messages
return nil
}
func (s *Store) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil && filepath.Dir(s.path) != "." {
return err
}
d := diskData{NextRoomID: s.nextRoomID, NextMessageID: s.nextMessageID, Rooms: s.rooms, Messages: s.messages}
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *Store) Rooms() ([]Room, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rooms := append([]Room(nil), s.rooms...)
sort.Slice(rooms, func(i, j int) bool { return rooms[i].Name < rooms[j].Name })
return rooms, nil
}
func (s *Store) CreateRoom(name, description string) (Room, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.rooms {
if r.Name == name {
return Room{}, errors.New("room exists")
}
}
r := Room{ID: s.nextRoomID, Name: name, Description: description, CreatedAt: time.Now().UTC()}
s.nextRoomID++
s.rooms = append(s.rooms, r)
if err := s.saveLocked(); err != nil {
return Room{}, err
}
return r, nil
}
func (s *Store) Room(id int64) (Room, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.rooms {
if r.ID == id {
return r, nil
}
}
return Room{}, ErrNotFound
}
func (s *Store) RecentMessages(roomID int64, limit int) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var msgs []Message
for _, m := range s.messages {
if m.RoomID == roomID {
msgs = append(msgs, m)
}
}
sort.Slice(msgs, func(i, j int) bool {
if msgs[i].CreatedAt.Equal(msgs[j].CreatedAt) {
return msgs[i].ID < msgs[j].ID
}
return msgs[i].CreatedAt.Before(msgs[j].CreatedAt)
})
if limit > 0 && len(msgs) > limit {
msgs = msgs[len(msgs)-limit:]
}
return append([]Message(nil), msgs...), nil
}
func (s *Store) AddMessage(roomID int64, username, body string) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
found := false
for _, r := range s.rooms {
if r.ID == roomID {
found = true
break
}
}
if !found {
return Message{}, ErrNotFound
}
m := Message{ID: s.nextMessageID, RoomID: roomID, Username: username, Body: body, CreatedAt: time.Now().UTC()}
s.nextMessageID++
s.messages = append(s.messages, m)
if err := s.saveLocked(); err != nil {
return Message{}, err
}
return m, nil
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}

358
internal/web/auth.go Normal file
View File

@@ -0,0 +1,358 @@
package web
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type AuthMode string
const (
AuthModeLocal AuthMode = "local"
AuthModeOIDC AuthMode = "oidc"
)
type AuthConfig struct {
Mode AuthMode
Issuer string
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
SessionSecret []byte
}
type oidcDiscovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
Issuer string `json:"issuer"`
}
type oauthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
}
type userClaims struct {
Subject string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Email string `json:"email"`
}
type sessionData struct {
Username string `json:"username"`
Subject string `json:"sub,omitempty"`
Email string `json:"email,omitempty"`
Expires int64 `json:"exp"`
}
func AuthConfigFromEnv(logger *slog.Logger) (AuthConfig, error) {
mode := AuthMode(strings.ToLower(strings.TrimSpace(getenv("AUTH_MODE", "local"))))
if mode != AuthModeLocal && mode != AuthModeOIDC {
return AuthConfig{}, fmt.Errorf("unsupported AUTH_MODE %q", mode)
}
secret := strings.TrimSpace(os.Getenv("SESSION_SECRET"))
var secretBytes []byte
if secret == "" {
secretBytes = make([]byte, 32)
if _, err := rand.Read(secretBytes); err != nil {
return AuthConfig{}, err
}
logger.Warn("SESSION_SECRET is not set; generated a temporary secret. Existing sessions will be invalid after restart")
} else {
decoded, err := base64.StdEncoding.DecodeString(secret)
if err == nil && len(decoded) >= 32 {
secretBytes = decoded
} else {
secretBytes = []byte(secret)
}
if len(secretBytes) < 32 {
return AuthConfig{}, errors.New("SESSION_SECRET must be at least 32 bytes or a base64 encoded 32 byte value")
}
}
cfg := AuthConfig{
Mode: mode,
Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OIDC_ISSUER")), "/"),
ClientID: strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")),
ClientSecret: strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")),
RedirectURL: strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")),
Scopes: splitScopes(getenv("OIDC_SCOPES", "openid profile email")),
SessionSecret: secretBytes,
}
if cfg.Mode == AuthModeOIDC {
if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" {
return AuthConfig{}, errors.New("AUTH_MODE=oidc requires OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL")
}
}
return cfg, nil
}
func (s *Server) discoverOIDC() error {
if s.auth.Mode != AuthModeOIDC {
return nil
}
discoveryURL := s.auth.Issuer + "/.well-known/openid-configuration"
req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("OIDC discovery failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("OIDC discovery returned %s", resp.Status)
}
var d oidcDiscovery
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil {
return err
}
if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.UserInfoEndpoint == "" {
return errors.New("OIDC discovery response is missing required endpoints")
}
s.oidc = d
return nil
}
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.auth.Mode != AuthModeOIDC {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
state, err := randomString(32)
if err != nil {
http.Error(w, "could not create login state", http.StatusInternalServerError)
return
}
setCookie(w, &http.Cookie{Name: "oauth_state", Value: state, MaxAge: 10 * 60, Path: "/auth", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)})
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", s.auth.ClientID)
q.Set("redirect_uri", s.auth.RedirectURL)
q.Set("scope", strings.Join(s.auth.Scopes, " "))
q.Set("state", state)
authURL := s.oidc.AuthorizationEndpoint + "?" + q.Encode()
http.Redirect(w, r, authURL, http.StatusFound)
}
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.auth.Mode != AuthModeOIDC {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if errText := r.URL.Query().Get("error"); errText != "" {
s.renderStatus(w, r, http.StatusUnauthorized, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode, "Error": "Login abgebrochen: " + errText})
return
}
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
clearCookie(w, "oauth_state", "/auth")
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
tok, err := s.exchangeCode(r.Context(), code)
if err != nil {
s.logger.Error("oauth token exchange", "error", err)
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
claims, err := s.fetchUserInfo(r.Context(), tok.AccessToken)
if err != nil {
s.logger.Error("oauth userinfo", "error", err)
http.Error(w, "could not fetch user info", http.StatusBadGateway)
return
}
username := normalizeUsername(claims)
if username == "" {
http.Error(w, "identity provider did not return a usable username", http.StatusBadGateway)
return
}
if err := s.setSession(w, r, sessionData{Username: username, Subject: claims.Subject, Email: claims.Email, Expires: time.Now().Add(30 * 24 * time.Hour).Unix()}); err != nil {
http.Error(w, "could not create session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/rooms", http.StatusSeeOther)
}
func (s *Server) exchangeCode(ctx context.Context, code string) (oauthTokenResponse, error) {
// Keep the public signature simple for Go 1.22 without adding external OAuth dependencies.
values := url.Values{}
values.Set("grant_type", "authorization_code")
values.Set("code", code)
values.Set("redirect_uri", s.auth.RedirectURL)
values.Set("client_id", s.auth.ClientID)
values.Set("client_secret", s.auth.ClientSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.oidc.TokenEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return oauthTokenResponse{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return oauthTokenResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return oauthTokenResponse{}, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return oauthTokenResponse{}, fmt.Errorf("token endpoint returned %s: %s", resp.Status, string(body))
}
var tok oauthTokenResponse
if err := json.Unmarshal(body, &tok); err != nil {
return oauthTokenResponse{}, err
}
if tok.AccessToken == "" {
return oauthTokenResponse{}, errors.New("token endpoint did not return access_token")
}
return tok, nil
}
func (s *Server) fetchUserInfo(ctx context.Context, accessToken string) (userClaims, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.oidc.UserInfoEndpoint, nil)
if err != nil {
return userClaims{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return userClaims{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return userClaims{}, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return userClaims{}, fmt.Errorf("userinfo endpoint returned %s: %s", resp.Status, string(body))
}
var claims userClaims
if err := json.Unmarshal(body, &claims); err != nil {
return userClaims{}, err
}
return claims, nil
}
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, data sessionData) error {
b, err := json.Marshal(data)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(b)
sig := sign(payload, s.auth.SessionSecret)
setCookie(w, &http.Cookie{Name: "chat_session", Value: payload + "." + sig, Path: "/", MaxAge: 60 * 60 * 24 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)})
return nil
}
func (s *Server) readSession(r *http.Request) (sessionData, bool) {
c, err := r.Cookie("chat_session")
if err != nil || c.Value == "" {
return sessionData{}, false
}
parts := strings.Split(c.Value, ".")
if len(parts) != 2 || !verify(parts[0], parts[1], s.auth.SessionSecret) {
return sessionData{}, false
}
b, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return sessionData{}, false
}
var data sessionData
if err := json.Unmarshal(b, &data); err != nil {
return sessionData{}, false
}
if data.Expires < time.Now().Unix() || strings.TrimSpace(data.Username) == "" {
return sessionData{}, false
}
return data, true
}
func sign(payload string, secret []byte) string {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func verify(payload, got string, secret []byte) bool {
expected := sign(payload, secret)
return hmac.Equal([]byte(expected), []byte(got))
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func normalizeUsername(c userClaims) string {
for _, v := range []string{c.PreferredUsername, c.Name, c.Email, c.Subject} {
v = strings.TrimSpace(v)
if v != "" {
if strings.Contains(v, "@") {
v = strings.Split(v, "@")[0]
}
if len(v) > 40 {
v = v[:40]
}
return v
}
}
return ""
}
func splitScopes(s string) []string {
fields := strings.Fields(s)
if len(fields) == 0 {
return []string{"openid", "profile", "email"}
}
return fields
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func setCookie(w http.ResponseWriter, c *http.Cookie) { http.SetCookie(w, c) }
func clearCookie(w http.ResponseWriter, name, path string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: path, MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
func isSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}

318
internal/web/server.go Normal file
View File

@@ -0,0 +1,318 @@
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
}