package store import ( "encoding/json" "errors" "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"` ReadAccess AccessRule `json:"read_access"` WriteAccess AccessRule `json:"write_access"` Members []string `json:"members"` CreatedAt time.Time `json:"created_at"` } type Message struct { ID int64 `json:"id"` RoomID int64 `json:"room_id"` Username string `json:"username"` Body string `json:"body"` 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 nextRoomID int64 nextMessageID int64 rooms []Room messages []Message users []User } type diskData struct { NextRoomID int64 `json:"next_room_id"` NextMessageID int64 `json:"next_message_id"` Rooms []Room `json:"rooms"` Messages []Message `json:"messages"` Users []User `json:"users"` } func Open(path string) (*Store, error) { s := &Store{path: path, nextRoomID: 1, nextMessageID: 1} if err := s.load(); err != nil { return nil, err } if len(s.rooms) == 0 { now := time.Now().UTC() s.rooms = []Room{ {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 { return nil, err } } return s, nil } func (s *Store) Close() error { return nil } func (s *Store) load() error { b, err := os.ReadFile(s.path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } var d diskData if err := json.Unmarshal(b, &d); err != nil { return err } s.nextRoomID = maxInt64(d.NextRoomID, 1) 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 } 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, Users: s.users} b, err := json.MarshalIndent(d, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } 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 strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name) }) return rooms, nil } 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 strings.EqualFold(r.Name, name) { return Room{}, errors.New("room exists") } } 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 { return Room{}, err } return r, nil } func (s *Store) Room(id int64) (Room, error) { s.mu.RLock() defer s.mu.RUnlock() for _, r := range s.rooms { if r.ID == id { return r, nil } } 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() var msgs []Message for _, m := range s.messages { if m.RoomID == roomID { msgs = append(msgs, m) } } sort.Slice(msgs, func(i, j int) bool { if msgs[i].CreatedAt.Equal(msgs[j].CreatedAt) { return msgs[i].ID < msgs[j].ID } return msgs[i].CreatedAt.Before(msgs[j].CreatedAt) }) if limit > 0 && len(msgs) > limit { msgs = msgs[len(msgs)-limit:] } return append([]Message(nil), msgs...), nil } func (s *Store) AddMessage(roomID int64, username, body string) (Message, error) { s.mu.Lock() defer s.mu.Unlock() found := false for _, r := range s.rooms { if r.ID == roomID { found = true break } } if !found { return Message{}, ErrNotFound } m := Message{ID: s.nextMessageID, RoomID: roomID, Username: username, Body: body, CreatedAt: time.Now().UTC()} s.nextMessageID++ s.messages = append(s.messages, m) if err := s.saveLocked(); err != nil { return Message{}, err } 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 } return b }