From 9ee6d3c919c6ef331fb6eb18acdab3376e085fa9 Mon Sep 17 00:00:00 2001 From: jbergner Date: Fri, 15 May 2026 09:08:50 +0200 Subject: [PATCH] =?UTF-8?q?update=20admin=20und=20l=C3=B6schung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + README.md | 42 +++++- internal/store/store.go | 278 ++++++++++++++++++++++++++++++++++++++-- internal/web/auth.go | 65 ++++++++-- internal/web/server.go | 227 ++++++++++++++++++++++++++------ static/app.css | 40 ++++++ templates/admin.html | 91 +++++++++++++ templates/login.html | 2 +- templates/room.html | 10 +- templates/rooms.html | 23 +++- 10 files changed, 715 insertions(+), 70 deletions(-) create mode 100644 templates/admin.html diff --git a/.env.example b/.env.example index d9d2eff..c63bc1c 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,12 @@ AUTH_MODE=local # OIDC_REDIRECT_URL=http://localhost:8080/auth/callback # OIDC_SCOPES=openid profile email +# Comma-separated usernames, emails or OIDC subjects that should always become admins. +# The very first user is also promoted to admin automatically. +CHAT_ADMINS=jan + +# Which global roles may create rooms. Valid: admin, moderator, user +ROOM_CREATE_ROLES=admin,moderator + # Generate with: openssl rand -base64 32 SESSION_SECRET=replace-with-a-random-32-byte-secret diff --git a/README.md b/README.md index 5fd7412..131d99d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Ein kleiner webbasierter Chat-Server mit Go, HTMX, Server-Sent Events, Datei-Per - mehrere Chaträume - neue Räume über das UI erstellen +- Rollen: `user`, `moderator`, `admin` +- Raumrechte getrennt für Lesen und Schreiben +- private Räume über Mitgliederlisten +- Admin-Seite für Rollen, Raumrechte und Nachrichten-Aufräumen - lokaler Entwicklungslogin oder echter Login via Pocket ID / OpenID Connect - signierte HttpOnly-Session-Cookies - Nachrichten senden per HTMX POST @@ -56,6 +60,8 @@ export OIDC_CLIENT_SECRET= export OIDC_REDIRECT_URL=http://localhost:8080/auth/callback export OIDC_SCOPES="openid profile email" export SESSION_SECRET=$(openssl rand -base64 32) +export CHAT_ADMINS=j.bergner.hilden@gmail.com +export ROOM_CREATE_ROLES=admin,moderator go run ./cmd/server ``` @@ -67,6 +73,40 @@ Hinweise: - Als Anzeigename wird bevorzugt `preferred_username` genutzt, danach `name`, `email` und zuletzt `sub`. - Hinter einem HTTPS-Reverse-Proxy sollte `X-Forwarded-Proto: https` gesetzt werden, damit Cookies als `Secure` markiert werden. + +## Berechtigungssystem + +Die App kennt drei globale Rollen: + +- `user`: normaler Nutzer +- `moderator`: kann standardmäßig Räume erstellen und darf member-Räume betreten +- `admin`: darf alles verwalten + +Der erste bekannte Benutzer wird automatisch Admin. Zusätzlich kannst du feste Admins über `CHAT_ADMINS` setzen. Der Wert ist eine kommaseparierte Liste aus Benutzernamen, E-Mail-Adressen oder OIDC-Subjects: + +```bash +export CHAT_ADMINS="jan,j.bergner.hilden@gmail.com" +``` + +Wer Räume erstellen darf, steuerst du über: + +```bash +export ROOM_CREATE_ROLES="admin,moderator" +``` + +Pro Raum gibt es getrennte Regeln für Lesen und Schreiben: + +- `everyone`: alle eingeloggten Benutzer +- `members`: nur eingetragene Mitglieder, Moderatoren und Admins +- `moderators`: Moderatoren und Admins +- `admins`: nur Admins + +Admins finden die Verwaltung unter `/admin`. Dort können sie Benutzerrollen ändern, Raumrechte setzen und alte Nachrichten löschen. + +## Alte Nachrichten aufräumen + +Unter `/admin` gibt es eine Retention-Aktion. Beispiel: `90` löscht alle Nachrichten, die älter als 90 Tage sind. Die Löschung ist dauerhaft, weil die aktuelle Persistenz in `data/chat.json` direkt überschrieben wird. + ## Projektstruktur ```text @@ -85,6 +125,6 @@ Diese Version ist gut als Basis für einen privaten oder internen Chat geeignet. - CSRF-Schutz für schreibende POST-Routen - Rate-Limits für Login, Raum-Erstellung und Nachrichten - SQLite oder PostgreSQL als Store -- Rollen, Moderation und Admin-Funktionen +- SQLite oder PostgreSQL als Store mit Migrationen - vollständige ID-Token/JWKS-Prüfung mit einer OIDC-Library - sichere Reverse-Proxy-Konfiguration mit HTTPS diff --git a/internal/store/store.go b/internal/store/store.go index 1ac959f..e76e028 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,17 +6,38 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "time" ) var ErrNotFound = errors.New("not found") +type Role string + +const ( + RoleUser Role = "user" + RoleModerator Role = "moderator" + RoleAdmin Role = "admin" +) + +type AccessRule string + +const ( + AccessEveryone AccessRule = "everyone" + AccessMembers AccessRule = "members" + AccessModerators AccessRule = "moderators" + AccessAdmins AccessRule = "admins" +) + type Room struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ReadAccess AccessRule `json:"read_access"` + WriteAccess AccessRule `json:"write_access"` + Members []string `json:"members"` + CreatedAt time.Time `json:"created_at"` } type Message struct { @@ -27,6 +48,15 @@ type Message struct { CreatedAt time.Time `json:"created_at"` } +type User struct { + Username string `json:"username"` + Subject string `json:"sub,omitempty"` + Email string `json:"email,omitempty"` + Role Role `json:"role"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` +} + type Store struct { mu sync.RWMutex path string @@ -34,6 +64,7 @@ type Store struct { nextMessageID int64 rooms []Room messages []Message + users []User } type diskData struct { @@ -41,6 +72,7 @@ type diskData struct { NextMessageID int64 `json:"next_message_id"` Rooms []Room `json:"rooms"` Messages []Message `json:"messages"` + Users []User `json:"users"` } func Open(path string) (*Store, error) { @@ -51,9 +83,9 @@ func Open(path string) (*Store, error) { 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}, + {ID: s.nextRoomID, Name: "Lobby", Description: "Allgemeiner Chat für alle", ReadAccess: AccessEveryone, WriteAccess: AccessEveryone, CreatedAt: now}, + {ID: s.nextRoomID + 1, Name: "Go", Description: "Golang, Backend und Deployment", ReadAccess: AccessEveryone, WriteAccess: AccessEveryone, CreatedAt: now}, + {ID: s.nextRoomID + 2, Name: "Random", Description: "Alles, was sonst nirgends passt", ReadAccess: AccessEveryone, WriteAccess: AccessEveryone, CreatedAt: now}, } s.nextRoomID += 3 if err := s.saveLocked(); err != nil { @@ -81,6 +113,15 @@ func (s *Store) load() error { s.nextMessageID = maxInt64(d.NextMessageID, 1) s.rooms = d.Rooms s.messages = d.Messages + s.users = d.Users + for i := range s.rooms { + s.rooms[i].ReadAccess = normalizeAccess(s.rooms[i].ReadAccess) + s.rooms[i].WriteAccess = normalizeAccess(s.rooms[i].WriteAccess) + s.rooms[i].Members = normalizeMembers(s.rooms[i].Members) + } + for i := range s.users { + s.users[i].Role = normalizeRole(s.users[i].Role) + } return nil } @@ -88,7 +129,7 @@ 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} + d := diskData{NextRoomID: s.nextRoomID, NextMessageID: s.nextMessageID, Rooms: s.rooms, Messages: s.messages, Users: s.users} b, err := json.MarshalIndent(d, "", " ") if err != nil { return err @@ -100,23 +141,95 @@ func (s *Store) saveLocked() error { return os.Rename(tmp, s.path) } +func (s *Store) EnsureUser(username, subject, email string, adminMatchers []string) (User, error) { + username = strings.TrimSpace(username) + email = strings.TrimSpace(email) + subject = strings.TrimSpace(subject) + if username == "" { + return User{}, errors.New("empty username") + } + now := time.Now().UTC() + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.users { + if sameIdentity(s.users[i], username, subject, email) { + s.users[i].Username = username + s.users[i].Subject = firstNonEmpty(s.users[i].Subject, subject) + s.users[i].Email = firstNonEmpty(s.users[i].Email, email) + if isAdminMatch(username, subject, email, adminMatchers) { + s.users[i].Role = RoleAdmin + } + s.users[i].LastSeen = now + if err := s.saveLocked(); err != nil { + return User{}, err + } + return s.users[i], nil + } + } + role := RoleUser + if len(s.users) == 0 || isAdminMatch(username, subject, email, adminMatchers) { + role = RoleAdmin + } + u := User{Username: username, Subject: subject, Email: email, Role: role, CreatedAt: now, LastSeen: now} + s.users = append(s.users, u) + if err := s.saveLocked(); err != nil { + return User{}, err + } + return u, nil +} + +func (s *Store) Users() ([]User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + users := append([]User(nil), s.users...) + sort.Slice(users, func(i, j int) bool { return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username) }) + return users, nil +} + +func (s *Store) SetUserRole(username string, role Role) error { + role = normalizeRole(role) + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.users { + if strings.EqualFold(s.users[i].Username, username) { + s.users[i].Role = role + return s.saveLocked() + } + } + return ErrNotFound +} + 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 }) + sort.Slice(rooms, func(i, j int) bool { return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name) }) return rooms, nil } -func (s *Store) CreateRoom(name, description string) (Room, error) { +func (s *Store) VisibleRooms(user User) ([]Room, error) { + rooms, err := s.Rooms() + if err != nil { + return nil, err + } + out := make([]Room, 0, len(rooms)) + for _, r := range rooms { + if CanAccessRoom(user, r, true) { + out = append(out, r) + } + } + return out, nil +} + +func (s *Store) CreateRoom(name, description string, readAccess, writeAccess AccessRule, members []string) (Room, error) { s.mu.Lock() defer s.mu.Unlock() for _, r := range s.rooms { - if r.Name == name { + if strings.EqualFold(r.Name, name) { return Room{}, errors.New("room exists") } } - r := Room{ID: s.nextRoomID, Name: name, Description: description, CreatedAt: time.Now().UTC()} + r := Room{ID: s.nextRoomID, Name: name, Description: description, ReadAccess: normalizeAccess(readAccess), WriteAccess: normalizeAccess(writeAccess), Members: normalizeMembers(members), CreatedAt: time.Now().UTC()} s.nextRoomID++ s.rooms = append(s.rooms, r) if err := s.saveLocked(); err != nil { @@ -136,6 +249,23 @@ func (s *Store) Room(id int64) (Room, error) { return Room{}, ErrNotFound } +func (s *Store) UpdateRoomPermissions(id int64, readAccess, writeAccess AccessRule, members []string) (Room, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.rooms { + if s.rooms[i].ID == id { + s.rooms[i].ReadAccess = normalizeAccess(readAccess) + s.rooms[i].WriteAccess = normalizeAccess(writeAccess) + s.rooms[i].Members = normalizeMembers(members) + if err := s.saveLocked(); err != nil { + return Room{}, err + } + return s.rooms[i], nil + } + } + return Room{}, ErrNotFound +} + func (s *Store) RecentMessages(roomID int64, limit int) ([]Message, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -179,6 +309,130 @@ func (s *Store) AddMessage(roomID int64, username, body string) (Message, error) return m, nil } +func (s *Store) CleanupMessagesOlderThan(cutoff time.Time) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + kept := s.messages[:0] + deleted := 0 + for _, m := range s.messages { + if m.CreatedAt.Before(cutoff) { + deleted++ + continue + } + kept = append(kept, m) + } + s.messages = kept + if deleted == 0 { + return 0, nil + } + return deleted, s.saveLocked() +} + +func CanAccessRoom(user User, room Room, read bool) bool { + if user.Role == RoleAdmin { + return true + } + rule := room.WriteAccess + if read { + rule = room.ReadAccess + } + rule = normalizeAccess(rule) + switch rule { + case AccessEveryone: + return true + case AccessMembers: + return isRoomMember(room, user.Username) || user.Role == RoleModerator + case AccessModerators: + return user.Role == RoleModerator || user.Role == RoleAdmin + case AccessAdmins: + return user.Role == RoleAdmin + default: + return false + } +} + +func isRoomMember(room Room, username string) bool { + for _, m := range room.Members { + if strings.EqualFold(strings.TrimSpace(m), strings.TrimSpace(username)) { + return true + } + } + return false +} + +func normalizeRole(role Role) Role { + switch Role(strings.ToLower(strings.TrimSpace(string(role)))) { + case RoleAdmin: + return RoleAdmin + case RoleModerator: + return RoleModerator + default: + return RoleUser + } +} + +func normalizeAccess(rule AccessRule) AccessRule { + switch AccessRule(strings.ToLower(strings.TrimSpace(string(rule)))) { + case AccessMembers: + return AccessMembers + case AccessModerators: + return AccessModerators + case AccessAdmins: + return AccessAdmins + default: + return AccessEveryone + } +} + +func normalizeMembers(members []string) []string { + seen := map[string]bool{} + var out []string + for _, m := range members { + m = strings.TrimSpace(m) + if m == "" { + continue + } + key := strings.ToLower(m) + if seen[key] { + continue + } + seen[key] = true + out = append(out, m) + } + sort.Strings(out) + return out +} + +func sameIdentity(u User, username, subject, email string) bool { + if subject != "" && u.Subject != "" && subject == u.Subject { + return true + } + if email != "" && u.Email != "" && strings.EqualFold(email, u.Email) { + return true + } + return strings.EqualFold(u.Username, username) +} + +func isAdminMatch(username, subject, email string, matchers []string) bool { + for _, m := range matchers { + m = strings.TrimSpace(strings.ToLower(m)) + if m == "" { + continue + } + if strings.EqualFold(m, username) || strings.EqualFold(m, email) || m == strings.ToLower(subject) { + return true + } + } + return false +} + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + func maxInt64(a, b int64) int64 { if a > b { return a diff --git a/internal/web/auth.go b/internal/web/auth.go index 533c28d..e07dd61 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -16,6 +16,8 @@ import ( "os" "strings" "time" + + "go-htmx-chat/internal/store" ) type AuthMode string @@ -26,13 +28,15 @@ const ( ) type AuthConfig struct { - Mode AuthMode - Issuer string - ClientID string - ClientSecret string - RedirectURL string - Scopes []string - SessionSecret []byte + Mode AuthMode + Issuer string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string + SessionSecret []byte + AdminMatchers []string + RoomCreatorRoles []store.Role } type oidcDiscovery struct { @@ -90,13 +94,15 @@ func AuthConfigFromEnv(logger *slog.Logger) (AuthConfig, error) { } 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, + 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, + AdminMatchers: splitCSV(os.Getenv("CHAT_ADMINS")), + RoomCreatorRoles: parseRoles(getenv("ROOM_CREATE_ROLES", "admin,moderator")), } if cfg.Mode == AuthModeOIDC { if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { @@ -356,3 +362,34 @@ func clearCookie(w http.ResponseWriter, name, path string) { func isSecure(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func parseRoles(s string) []store.Role { + parts := strings.Split(s, ",") + out := make([]store.Role, 0, len(parts)) + for _, p := range parts { + switch store.Role(strings.ToLower(strings.TrimSpace(p))) { + case store.RoleAdmin: + out = append(out, store.RoleAdmin) + case store.RoleModerator: + out = append(out, store.RoleModerator) + case store.RoleUser: + out = append(out, store.RoleUser) + } + } + if len(out) == 0 { + return []store.Role{store.RoleAdmin} + } + return out +} diff --git a/internal/web/server.go b/internal/web/server.go index c022192..1a1cd5d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -28,7 +28,8 @@ type Server struct { 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") }, + "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 == "" { @@ -39,6 +40,7 @@ func NewServer(st *store.Store, hub *chat.Hub, logger *slog.Logger, auth AuthCon } return "?" }, + "join": strings.Join, } tmpl, err := template.New("base").Funcs(funcs).ParseGlob("templates/**/*.html") if err != nil { @@ -69,6 +71,10 @@ func (s *Server) Routes() http.Handler { 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)) } @@ -93,11 +99,12 @@ func (s *Server) recover(next http.Handler) http.Handler { } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { - username := s.currentUser(r) - if username == "" { + 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) } @@ -111,14 +118,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { 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, - } + 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) @@ -131,21 +131,25 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRooms(w http.ResponseWriter, r *http.Request) { - username := s.requireUser(w, r) - if username == "" { + user, ok := s.requireUser(w, r) + if !ok { return } - rooms, err := s.store.Rooms() + 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", "Username": username, "Rooms": rooms}) + 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) { - username := s.requireUser(w, r) - if username == "" { + 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")) @@ -154,7 +158,10 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { http.Error(w, "Raumname muss 2 bis 40 Zeichen haben.", http.StatusBadRequest) return } - room, err := s.store.CreateRoom(name, description) + 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 @@ -164,8 +171,8 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRoom(w http.ResponseWriter, r *http.Request) { - username := s.requireUser(w, r) - if username == "" { + user, ok := s.requireUser(w, r) + if !ok { return } roomID, ok := parseID(w, r) @@ -181,23 +188,40 @@ func (s *Server) handleRoom(w http.ResponseWriter, r *http.Request) { 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, "Username": username, "Room": room, "Messages": messages}) + 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) { - username := s.requireUser(w, r) - if username == "" { + 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) @@ -207,27 +231,34 @@ func (s *Server) handlePostMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Nachricht ist zu lang.", http.StatusBadRequest) return } - msg, err := s.store.AddMessage(roomID, username, body) + msg, err := s.store.AddMessage(roomID, user.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 == "" { + 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 { @@ -267,6 +298,74 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } } +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 { @@ -276,27 +375,79 @@ func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { return id, true } -func (s *Server) currentUser(r *http.Request) string { +func (s *Server) currentUser(r *http.Request) (store.User, bool) { if sess, ok := s.readSession(r); ok { - return strings.TrimSpace(sess.Username) + 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 "" + return store.User{}, false } - return strings.TrimSpace(c.Value) + 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 "" + return store.User{}, false } -func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) string { - username := s.currentUser(r) - if username == "" { +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 "" + return store.User{}, false } - return username + 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) { diff --git a/static/app.css b/static/app.css index 30903d8..387f02b 100644 --- a/static/app.css +++ b/static/app.css @@ -120,3 +120,43 @@ code { background: rgba(2, 6, 23, .55); color: var(--text); } +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 1rem; + background: rgba(2, 6, 23, .72); + color: var(--text); + padding: .9rem 1rem; + outline: none; +} +select:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(56,189,248,.12); } +small { color: var(--muted); } +.wide { grid-column: 1 / -1; } +.notice { border: 1px solid rgba(56,189,248,.35); background: rgba(56,189,248,.10); color: #bae6fd; padding: .8rem 1rem; border-radius: 1rem; } +.link-button { display: inline-flex; align-items: center; justify-content: center; padding: .75rem 1rem; border-radius: 999px; } +.table-list { display: grid; gap: .75rem; } +.table-row { + display: grid; + grid-template-columns: minmax(190px, 1.2fr) minmax(150px, .9fr) minmax(140px, .7fr) auto; + gap: .75rem; + align-items: center; + border: 1px solid var(--line); + border-radius: 1rem; + padding: .8rem; + background: rgba(2, 6, 23, .25); +} +.permission-card { + display: grid; + grid-template-columns: minmax(220px, 1.2fr) minmax(130px, .7fr) minmax(130px, .7fr) minmax(180px, 1fr) auto; + gap: .75rem; + align-items: end; + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: rgba(2, 6, 23, .25); +} +.permission-card h3 { margin: 0 0 .3rem; } +.permission-card p { margin: 0; } +@media (max-width: 980px) { + .table-row, .permission-card { grid-template-columns: 1fr; align-items: stretch; } +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a442e49 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,91 @@ + + + + + + {{.Title}} · SEND.NRW Chat + + + + +
+
+ SEND.NRW Chat +
+ {{.Username}} · {{.User.Role}} + +
+
+ +
+
+
+

Administration

+

Berechtigungen & Aufräumen

+

Admins verwalten Rollen, Raumrechte und die Nachrichten-Retention.

+ {{if .Notice}}

{{.Notice}}

{{end}} +
+
+ +
+

Benutzerrollen

+
+ {{range .Users}} +
+
{{.Username}}
{{.Email}} {{.Subject}}
+
Zuletzt: {{formatDateTime .LastSeen}}
+ + +
+ {{end}} +
+
+ +
+

Raumrechte

+

Mitglieder gelten bei der Regel members. Moderatoren dürfen member-Räume ebenfalls sehen/schreiben.

+
+ {{range .Rooms}} +
+
+

{{.Name}}

+

{{.Description}}

+
+ + + + +
+ {{end}} +
+
+ +
+

Alte Nachrichten löschen

+
+ + +
+

Beispiel: 90 löscht alle Nachrichten, die älter als 90 Tage sind.

+
+
+
+ + diff --git a/templates/login.html b/templates/login.html index 46b5d58..e184466 100644 --- a/templates/login.html +++ b/templates/login.html @@ -9,7 +9,7 @@
-
Go HTMX Chat
+
SEND.NRW Chat
+ {{if .CanWrite}}
+ {{else}} +
Du hast in diesem Raum nur Leserechte.
+ {{end}} diff --git a/templates/rooms.html b/templates/rooms.html index 4c0661d..b4ac057 100644 --- a/templates/rooms.html +++ b/templates/rooms.html @@ -12,7 +12,8 @@
SEND.NRW Chat
- {{.Username}} + {{if .CanAdmin}}Admin{{end}} + {{.Username}} · {{.User.Role}}
@@ -21,7 +22,7 @@

Räume

Wähle einen Chatraum

-

Nachrichten werden persistent gespeichert und live per Server-Sent Events verteilt.

+

Du siehst nur Räume, für die du Leserechte hast. Schreibrechte werden im Raum geprüft.

@@ -30,18 +31,36 @@

{{.Name}}

{{.Description}}

+ Lesen: {{.ReadAccess}} · Schreiben: {{.WriteAccess}}
+ {{else}} +

Aktuell ist kein Raum für dich freigegeben.

{{end}} + {{if .CanCreateRoom}}

Neuen Raum erstellen

+ + +
+ {{end}}