All checks were successful
release-tag / release-image (push) Successful in 1m41s
442 lines
11 KiB
Go
442 lines
11 KiB
Go
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
|
|
}
|