Files
chat/internal/web/server.go
jbergner 9ee6d3c919
All checks were successful
release-tag / release-image (push) Successful in 1m41s
update admin und löschung
2026-05-15 09:08:50 +02:00

470 lines
14 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") },
"formatDateTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
"initial": func(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "?"
}
for _, r := range s {
return strings.ToUpper(string(r))
}
return "?"
},
"join": strings.Join,
}
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)
mux.HandleFunc("GET /admin", s.handleAdmin)
mux.HandleFunc("POST /admin/users/{username}/role", s.handleSetUserRole)
mux.HandleFunc("POST /admin/rooms/{id}/permissions", s.handleRoomPermissions)
mux.HandleFunc("POST /admin/cleanup", s.handleCleanup)
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) {
user, ok := s.currentUser(r)
if !ok {
s.render(w, r, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode})
return
}
_ = user
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) {
user, ok := s.requireUser(w, r)
if !ok {
return
}
rooms, err := s.store.VisibleRooms(user)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
s.render(w, r, "rooms.html", map[string]any{"Title": "Räume", "User": user, "Username": user.Username, "Rooms": rooms, "CanCreateRoom": s.canCreateRoom(user), "CanAdmin": user.Role == store.RoleAdmin, "AccessRules": accessRules()})
}
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireUser(w, r)
if !ok {
return
}
if !s.canCreateRoom(user) {
http.Error(w, "Du darfst keine Räume erstellen.", http.StatusForbidden)
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
}
readAccess := store.AccessRule(r.FormValue("read_access"))
writeAccess := store.AccessRule(r.FormValue("write_access"))
members := splitMembers(r.FormValue("members"))
room, err := s.store.CreateRoom(name, description, readAccess, writeAccess, members)
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) {
user, ok := s.requireUser(w, r)
if !ok {
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
}
if !store.CanAccessRoom(user, room, true) {
http.Error(w, "Du darfst diesen Raum nicht sehen.", http.StatusForbidden)
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, "User": user, "Username": user.Username, "Room": room, "Messages": messages, "CanWrite": store.CanAccessRoom(user, room, false), "CanAdmin": user.Role == store.RoleAdmin})
}
func (s *Server) handlePostMessage(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireUser(w, r)
if !ok {
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
}
if !store.CanAccessRoom(user, room, false) {
http.Error(w, "Du darfst in diesem Raum nicht schreiben.", http.StatusForbidden)
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, user.Username, body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
s.hub.Publish(msg)
w.Header().Set("HX-Trigger", "message-sent")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireUser(w, r)
if !ok {
return
}
roomID, ok := parseID(w, r)
if !ok {
return
}
room, err := s.store.Room(roomID)
if err != nil {
http.NotFound(w, r)
return
}
if !store.CanAccessRoom(user, room, true) {
http.Error(w, "forbidden", http.StatusForbidden)
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 (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAdmin(w, r)
if !ok {
return
}
users, err := s.store.Users()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
rooms, err := s.store.Rooms()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
s.render(w, r, "admin.html", map[string]any{"Title": "Admin", "User": user, "Username": user.Username, "Users": users, "Rooms": rooms, "Roles": []store.Role{store.RoleUser, store.RoleModerator, store.RoleAdmin}, "AccessRules": accessRules(), "Notice": r.URL.Query().Get("notice")})
}
func (s *Server) handleSetUserRole(w http.ResponseWriter, r *http.Request) {
_, ok := s.requireAdmin(w, r)
if !ok {
return
}
username := r.PathValue("username")
role := store.Role(r.FormValue("role"))
if err := s.store.SetUserRole(username, role); err != nil {
http.Error(w, err.Error(), 500)
return
}
http.Redirect(w, r, "/admin?notice=Rolle+gespeichert", http.StatusSeeOther)
}
func (s *Server) handleRoomPermissions(w http.ResponseWriter, r *http.Request) {
_, ok := s.requireAdmin(w, r)
if !ok {
return
}
roomID, ok := parseID(w, r)
if !ok {
return
}
_, err := s.store.UpdateRoomPermissions(roomID, store.AccessRule(r.FormValue("read_access")), store.AccessRule(r.FormValue("write_access")), splitMembers(r.FormValue("members")))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
http.Redirect(w, r, "/admin?notice=Raumrechte+gespeichert", http.StatusSeeOther)
}
func (s *Server) handleCleanup(w http.ResponseWriter, r *http.Request) {
_, ok := s.requireAdmin(w, r)
if !ok {
return
}
days, err := strconv.Atoi(strings.TrimSpace(r.FormValue("retention_days")))
if err != nil || days < 1 {
http.Error(w, "Bitte eine Aufbewahrung in Tagen >= 1 angeben.", http.StatusBadRequest)
return
}
cutoff := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
deleted, err := s.store.CleanupMessagesOlderThan(cutoff)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin?notice=%d+alte+Nachrichten+gelöscht", deleted), http.StatusSeeOther)
}
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) (store.User, bool) {
if sess, ok := s.readSession(r); ok {
u, err := s.store.EnsureUser(strings.TrimSpace(sess.Username), strings.TrimSpace(sess.Subject), strings.TrimSpace(sess.Email), s.auth.AdminMatchers)
if err == nil {
return u, true
}
s.logger.Error("ensure user", "error", err)
return store.User{}, false
}
if s.auth.Mode == AuthModeLocal {
c, err := r.Cookie("chat_user")
if err != nil {
return store.User{}, false
}
username := strings.TrimSpace(c.Value)
if username == "" {
return store.User{}, false
}
u, err := s.store.EnsureUser(username, "", "", s.auth.AdminMatchers)
if err == nil {
return u, true
}
s.logger.Error("ensure local user", "error", err)
}
return store.User{}, false
}
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
user, ok := s.currentUser(r)
if !ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return store.User{}, false
}
return user, true
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (store.User, bool) {
user, ok := s.requireUser(w, r)
if !ok {
return store.User{}, false
}
if user.Role != store.RoleAdmin {
http.Error(w, "Admin-Rechte erforderlich.", http.StatusForbidden)
return store.User{}, false
}
return user, true
}
func (s *Server) canCreateRoom(user store.User) bool {
if user.Role == store.RoleAdmin {
return true
}
for _, role := range s.auth.RoomCreatorRoles {
if user.Role == role {
return true
}
}
return false
}
func accessRules() []store.AccessRule {
return []store.AccessRule{store.AccessEveryone, store.AccessMembers, store.AccessModerators, store.AccessAdmins}
}
func splitMembers(s string) []string {
fields := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' || r == '\t' })
out := make([]string, 0, len(fields))
for _, f := range fields {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
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
}