update admin und löschung
All checks were successful
release-tag / release-image (push) Successful in 1m41s
All checks were successful
release-tag / release-image (push) Successful in 1m41s
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user