update admin und löschung
All checks were successful
release-tag / release-image (push) Successful in 1m41s

This commit is contained in:
2026-05-15 09:08:50 +02:00
parent be7bd79fc7
commit 9ee6d3c919
10 changed files with 715 additions and 70 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {