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:
@@ -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
|
||||
|
||||
42
README.md
42
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=<client-secret-aus-pocket-id>
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
91
templates/admin.html
Normal file
91
templates/admin.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · SEND.NRW Chat</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/rooms">SEND.NRW Chat</a>
|
||||
<form method="post" action="/logout">
|
||||
<span class="user-pill">{{.Username}} · {{.User.Role}}</span>
|
||||
<button class="ghost" type="submit">Logout</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main class="grid-page">
|
||||
<section class="hero card">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Berechtigungen & Aufräumen</h1>
|
||||
<p class="muted">Admins verwalten Rollen, Raumrechte und die Nachrichten-Retention.</p>
|
||||
{{if .Notice}}<p class="notice">{{.Notice}}</p>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Benutzerrollen</h2>
|
||||
<div class="table-list">
|
||||
{{range .Users}}
|
||||
<form class="table-row" method="post" action="/admin/users/{{.Username}}/role">
|
||||
<div><strong>{{.Username}}</strong><br><small>{{.Email}} {{.Subject}}</small></div>
|
||||
<div><small>Zuletzt: {{formatDateTime .LastSeen}}</small></div>
|
||||
<select name="role">
|
||||
{{$current := .Role}}
|
||||
{{range $.Roles}}<option value="{{.}}" {{if eq . $current}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Raumrechte</h2>
|
||||
<p class="muted">Mitglieder gelten bei der Regel <code>members</code>. Moderatoren dürfen member-Räume ebenfalls sehen/schreiben.</p>
|
||||
<div class="stack">
|
||||
{{range .Rooms}}
|
||||
<form class="permission-card" method="post" action="/admin/rooms/{{.ID}}/permissions">
|
||||
<div>
|
||||
<h3>{{.Name}}</h3>
|
||||
<p class="muted">{{.Description}}</p>
|
||||
</div>
|
||||
<label>Lesen
|
||||
<select name="read_access">
|
||||
{{$read := .ReadAccess}}
|
||||
{{range $.AccessRules}}<option value="{{.}}" {{if eq . $read}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Schreiben
|
||||
<select name="write_access">
|
||||
{{$write := .WriteAccess}}
|
||||
{{range $.AccessRules}}<option value="{{.}}" {{if eq . $write}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Mitglieder
|
||||
<input name="members" value="{{join .Members ", "}}" placeholder="jan, anna, max">
|
||||
</label>
|
||||
<button type="submit">Rechte speichern</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Alte Nachrichten löschen</h2>
|
||||
<form method="post" action="/admin/cleanup" class="two-col-form">
|
||||
<label>Aufbewahrung in Tagen
|
||||
<input type="number" min="1" name="retention_days" value="90" required>
|
||||
</label>
|
||||
<button type="submit">Ältere Nachrichten löschen</button>
|
||||
</form>
|
||||
<p class="muted">Beispiel: 90 löscht alle Nachrichten, die älter als 90 Tage sind.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar"><a class="brand" href="/rooms">Go HTMX Chat</a></header>
|
||||
<header class="topbar"><a class="brand" href="/rooms">SEND.NRW Chat</a></header>
|
||||
<main class="center-card">
|
||||
<section class="card login-card">
|
||||
<p class="eyebrow">Willkommen</p>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/rooms">SEND.NRW Chat</a>
|
||||
<form method="post" action="/logout">
|
||||
<span class="user-pill">{{.Username}}</span>
|
||||
{{if .CanAdmin}}<a class="ghost link-button" href="/admin">Admin</a>{{end}}
|
||||
<span class="user-pill">{{.Username}} · {{.User.Role}}</span>
|
||||
<button class="ghost" type="submit">Logout</button>
|
||||
</form>
|
||||
</header>
|
||||
@@ -23,7 +24,8 @@
|
||||
<p class="eyebrow">Chatraum</p>
|
||||
<h1>{{.Room.Name}}</h1>
|
||||
<p class="muted">{{.Room.Description}}</p>
|
||||
<div class="hint"></div>
|
||||
<div class="hint">Lesen: {{.Room.ReadAccess}}<br>Schreiben: {{.Room.WriteAccess}}</div>
|
||||
{{if .Room.Members}}<div class="hint">Mitglieder: {{join .Room.Members ", "}}</div>{{end}}
|
||||
</aside>
|
||||
|
||||
<section class="chat-card card">
|
||||
@@ -35,10 +37,14 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .CanWrite}}
|
||||
<form id="message-form" class="message-form" hx-post="/rooms/{{.Room.ID}}/messages" hx-swap="none" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||
<input name="body" placeholder="Nachricht schreiben …" autocomplete="off" required maxlength="2000">
|
||||
<button type="submit">Senden</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="hint">Du hast in diesem Raum nur Leserechte.</div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/rooms">SEND.NRW Chat</a>
|
||||
<form method="post" action="/logout">
|
||||
<span class="user-pill">{{.Username}}</span>
|
||||
{{if .CanAdmin}}<a class="ghost link-button" href="/admin">Admin</a>{{end}}
|
||||
<span class="user-pill">{{.Username}} · {{.User.Role}}</span>
|
||||
<button class="ghost" type="submit">Logout</button>
|
||||
</form>
|
||||
</header>
|
||||
@@ -21,7 +22,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Räume</p>
|
||||
<h1>Wähle einen Chatraum</h1>
|
||||
<p class="muted">Nachrichten werden persistent gespeichert und live per Server-Sent Events verteilt.</p>
|
||||
<p class="muted">Du siehst nur Räume, für die du Leserechte hast. Schreibrechte werden im Raum geprüft.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -30,18 +31,36 @@
|
||||
<a class="room-card" href="/rooms/{{.ID}}">
|
||||
<h2>{{.Name}}</h2>
|
||||
<p>{{.Description}}</p>
|
||||
<small>Lesen: {{.ReadAccess}} · Schreiben: {{.WriteAccess}}</small>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="card"><p class="muted">Aktuell ist kein Raum für dich freigegeben.</p></div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .CanCreateRoom}}
|
||||
<section class="card hero">
|
||||
<h2>Neuen Raum erstellen</h2>
|
||||
<form hx-post="/rooms" method="post" class="stack two-col-form">
|
||||
<label>Name<input name="name" placeholder="Projekt Alpha" required minlength="2" maxlength="40"></label>
|
||||
<label>Beschreibung<input name="description" placeholder="Worum geht es hier?" maxlength="120"></label>
|
||||
<label>Wer darf sehen?
|
||||
<select name="read_access">
|
||||
{{range .AccessRules}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Wer darf schreiben?
|
||||
<select name="write_access">
|
||||
{{range .AccessRules}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">Mitglieder für private Räume
|
||||
<input name="members" placeholder="jan, anna, max">
|
||||
</label>
|
||||
<button type="submit">Raum erstellen</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user