Update
This commit is contained in:
604
src/state/manager.go
Normal file
604
src/state/manager.go
Normal file
@@ -0,0 +1,604 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]*Room
|
||||
store *DiskStore
|
||||
}
|
||||
|
||||
func NewManager(dataPath string) (*Manager, error) {
|
||||
store, err := NewDiskStore(dataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
rooms: make(map[string]*Room),
|
||||
store: store,
|
||||
}
|
||||
|
||||
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||
roomName := normalizeName(input.RoomName, 80)
|
||||
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
||||
if roomName == "" {
|
||||
roomName = "Scrum Poker Room"
|
||||
}
|
||||
if creatorUsername == "" {
|
||||
creatorUsername = "host"
|
||||
}
|
||||
|
||||
maxPeople := input.MaxPeople
|
||||
if maxPeople < 2 {
|
||||
maxPeople = 2
|
||||
}
|
||||
if maxPeople > 50 {
|
||||
maxPeople = 50
|
||||
}
|
||||
|
||||
revealMode := input.RevealMode
|
||||
if revealMode != RevealModeManual && revealMode != RevealModeAutoAll {
|
||||
revealMode = RevealModeManual
|
||||
}
|
||||
|
||||
cards := make([]string, 0, len(input.Cards))
|
||||
for _, rawCard := range input.Cards {
|
||||
card := normalizeCard(rawCard)
|
||||
if card == "" {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
cards = []string{"0", "1", "2", "3", "5", "8", "13", "21", "?"}
|
||||
}
|
||||
|
||||
roomID := newUUIDv4()
|
||||
adminToken := randomHex(24)
|
||||
creatorID := newUUIDv4()
|
||||
now := nowUTC()
|
||||
|
||||
settings := RoomSettings{
|
||||
RoomName: roomName,
|
||||
MaxPeople: maxPeople,
|
||||
Cards: cards,
|
||||
AllowSpectators: input.AllowSpectators,
|
||||
AnonymousVoting: input.AnonymousVoting,
|
||||
AutoReset: input.AutoReset,
|
||||
RevealMode: revealMode,
|
||||
VotingTimeoutSec: max(0, input.VotingTimeoutSec),
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(input.Password)
|
||||
if password != "" {
|
||||
settings.PasswordSalt = randomHex(16)
|
||||
settings.PasswordHash = hashPassword(password, settings.PasswordSalt)
|
||||
}
|
||||
|
||||
creator := &Participant{
|
||||
ID: creatorID,
|
||||
Username: creatorUsername,
|
||||
Role: RoleParticipant,
|
||||
IsAdmin: true,
|
||||
Connected: true,
|
||||
HasVoted: false,
|
||||
JoinedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
room := &Room{
|
||||
ID: roomID,
|
||||
AdminToken: adminToken,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Settings: settings,
|
||||
Round: RoundState{
|
||||
Revealed: false,
|
||||
},
|
||||
Participants: map[string]*Participant{
|
||||
creatorID: creator,
|
||||
},
|
||||
subscribers: map[string]*subscriber{},
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.rooms[roomID] = room
|
||||
m.mu.Unlock()
|
||||
|
||||
room.mu.Lock()
|
||||
if err := m.store.Save(room); err != nil {
|
||||
room.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
delete(m.rooms, roomID)
|
||||
m.mu.Unlock()
|
||||
return CreateRoomResult{}, err
|
||||
}
|
||||
room.mu.Unlock()
|
||||
|
||||
result := CreateRoomResult{
|
||||
RoomID: roomID,
|
||||
CreatorParticipantID: creatorID,
|
||||
AdminToken: adminToken,
|
||||
ParticipantLink: "/room/" + roomID,
|
||||
AdminLink: "/room/" + roomID + "?adminToken=" + adminToken,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, error) {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return JoinRoomResult{}, err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
username := normalizeName(input.Username, 32)
|
||||
if username == "" {
|
||||
username = "anonymous"
|
||||
}
|
||||
|
||||
role := input.Role
|
||||
if role == "" {
|
||||
role = RoleParticipant
|
||||
}
|
||||
if role != RoleParticipant && role != RoleViewer {
|
||||
return JoinRoomResult{}, ErrInvalidRole
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
isAdminByToken := input.AdminToken != "" && input.AdminToken == room.AdminToken
|
||||
|
||||
if room.Settings.PasswordHash != "" && input.ParticipantID == "" {
|
||||
if !passwordMatches(input.Password, room.Settings.PasswordSalt, room.Settings.PasswordHash) {
|
||||
return JoinRoomResult{}, ErrPasswordRequired
|
||||
}
|
||||
}
|
||||
|
||||
if input.ParticipantID != "" {
|
||||
existing, ok := room.Participants[input.ParticipantID]
|
||||
if !ok {
|
||||
return JoinRoomResult{}, ErrParticipantNotFound
|
||||
}
|
||||
|
||||
existing.Username = username
|
||||
existing.Connected = true
|
||||
existing.UpdatedAt = now
|
||||
if isAdminByToken {
|
||||
existing.IsAdmin = true
|
||||
}
|
||||
|
||||
room.UpdatedAt = now
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return JoinRoomResult{}, err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return JoinRoomResult{
|
||||
ParticipantID: existing.ID,
|
||||
IsAdmin: existing.IsAdmin,
|
||||
Role: existing.Role,
|
||||
Username: existing.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if role == RoleViewer && !room.Settings.AllowSpectators {
|
||||
return JoinRoomResult{}, ErrSpectatorsBlocked
|
||||
}
|
||||
|
||||
if role == RoleParticipant {
|
||||
count := 0
|
||||
for _, participant := range room.Participants {
|
||||
if participant.Role == RoleParticipant {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count >= room.Settings.MaxPeople {
|
||||
return JoinRoomResult{}, ErrRoomFull
|
||||
}
|
||||
}
|
||||
|
||||
participant := &Participant{
|
||||
ID: newUUIDv4(),
|
||||
Username: username,
|
||||
Role: role,
|
||||
IsAdmin: isAdminByToken,
|
||||
Connected: true,
|
||||
HasVoted: false,
|
||||
JoinedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
room.Participants[participant.ID] = participant
|
||||
room.UpdatedAt = now
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return JoinRoomResult{}, err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return JoinRoomResult{
|
||||
ParticipantID: participant.ID,
|
||||
IsAdmin: participant.IsAdmin,
|
||||
Role: participant.Role,
|
||||
Username: participant.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LeaveRoom(roomID, participantID string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
}
|
||||
|
||||
participant.Connected = false
|
||||
participant.UpdatedAt = nowUTC()
|
||||
room.UpdatedAt = nowUTC()
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CastVote(roomID, participantID, card string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
}
|
||||
if participant.Role != RoleParticipant {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
normalizedCard := normalizeCard(card)
|
||||
if normalizedCard == "" || !slices.Contains(room.Settings.Cards, normalizedCard) {
|
||||
return ErrInvalidCard
|
||||
}
|
||||
|
||||
if room.Round.Revealed {
|
||||
if room.Settings.AutoReset {
|
||||
m.resetVotesLocked(room)
|
||||
} else {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
participant.HasVoted = true
|
||||
participant.VoteValue = normalizedCard
|
||||
participant.UpdatedAt = nowUTC()
|
||||
room.UpdatedAt = nowUTC()
|
||||
|
||||
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
|
||||
room.Round.Revealed = true
|
||||
}
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) RevealVotes(roomID, participantID string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
}
|
||||
if !participant.IsAdmin {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
room.Round.Revealed = true
|
||||
room.UpdatedAt = nowUTC()
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ResetVotes(roomID, participantID string) error {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
return ErrParticipantNotFound
|
||||
}
|
||||
if !participant.IsAdmin {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
m.resetVotesLocked(room)
|
||||
room.UpdatedAt = nowUTC()
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.broadcastRoom(room.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
participant, ok := room.Participants[participantID]
|
||||
if !ok {
|
||||
room.mu.Unlock()
|
||||
return nil, nil, nil, ErrParticipantNotFound
|
||||
}
|
||||
|
||||
participant.Connected = true
|
||||
participant.UpdatedAt = nowUTC()
|
||||
subscriberID := randomHex(12)
|
||||
ch := make(chan []byte, 16)
|
||||
room.subscribers[subscriberID] = &subscriber{participantID: participantID, ch: ch}
|
||||
room.UpdatedAt = nowUTC()
|
||||
|
||||
if err := m.store.Save(room); err != nil {
|
||||
room.mu.Unlock()
|
||||
close(ch)
|
||||
delete(room.subscribers, subscriberID)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
room.mu.Unlock()
|
||||
|
||||
initial, marshalErr := m.marshalRoomState(room, participantID)
|
||||
if marshalErr != nil {
|
||||
room.mu.Lock()
|
||||
delete(room.subscribers, subscriberID)
|
||||
close(ch)
|
||||
room.mu.Unlock()
|
||||
return nil, nil, nil, marshalErr
|
||||
}
|
||||
|
||||
unsubscribe := func() {
|
||||
roomRef, getErr := m.getRoom(roomID)
|
||||
if getErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
roomRef.mu.Lock()
|
||||
_, exists := roomRef.subscribers[subscriberID]
|
||||
if !exists {
|
||||
roomRef.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(roomRef.subscribers, subscriberID)
|
||||
|
||||
if p, participantOK := roomRef.Participants[participantID]; participantOK {
|
||||
p.Connected = false
|
||||
p.UpdatedAt = nowUTC()
|
||||
roomRef.UpdatedAt = nowUTC()
|
||||
_ = m.store.Save(roomRef)
|
||||
}
|
||||
roomRef.mu.Unlock()
|
||||
|
||||
go m.broadcastRoom(roomID)
|
||||
}
|
||||
|
||||
go m.broadcastRoom(roomID)
|
||||
return ch, initial, unsubscribe, nil
|
||||
}
|
||||
|
||||
func (m *Manager) getRoom(roomID string) (*Room, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
room, ok := m.rooms[roomID]
|
||||
if !ok {
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
return room, nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadFromDisk() error {
|
||||
persistedRooms, err := m.store.LoadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, persisted := range persistedRooms {
|
||||
room := &Room{
|
||||
ID: persisted.ID,
|
||||
AdminToken: persisted.AdminToken,
|
||||
CreatedAt: persisted.CreatedAt,
|
||||
UpdatedAt: persisted.UpdatedAt,
|
||||
Settings: persisted.Settings,
|
||||
Round: persisted.Round,
|
||||
Participants: make(map[string]*Participant, len(persisted.Participants)),
|
||||
subscribers: map[string]*subscriber{},
|
||||
}
|
||||
for _, participant := range persisted.Participants {
|
||||
participant.Connected = false
|
||||
room.Participants[participant.ID] = participant
|
||||
}
|
||||
|
||||
m.rooms[room.ID] = room
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (room *Room) toPersisted() persistedRoom {
|
||||
participants := make([]*Participant, 0, len(room.Participants))
|
||||
for _, participant := range sortParticipants(room.Participants) {
|
||||
clone := *participant
|
||||
participants = append(participants, &clone)
|
||||
}
|
||||
|
||||
return persistedRoom{
|
||||
ID: room.ID,
|
||||
AdminToken: room.AdminToken,
|
||||
CreatedAt: room.CreatedAt,
|
||||
UpdatedAt: room.UpdatedAt,
|
||||
Settings: room.Settings,
|
||||
Round: room.Round,
|
||||
Participants: participants,
|
||||
}
|
||||
}
|
||||
|
||||
func allActiveParticipantsVoted(room *Room) bool {
|
||||
activeParticipants := 0
|
||||
for _, participant := range room.Participants {
|
||||
if participant.Role != RoleParticipant || !participant.Connected {
|
||||
continue
|
||||
}
|
||||
activeParticipants++
|
||||
if !participant.HasVoted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return activeParticipants > 0
|
||||
}
|
||||
|
||||
func (m *Manager) resetVotesLocked(room *Room) {
|
||||
room.Round.Revealed = false
|
||||
for _, participant := range room.Participants {
|
||||
if participant.Role != RoleParticipant {
|
||||
continue
|
||||
}
|
||||
participant.HasVoted = false
|
||||
participant.VoteValue = ""
|
||||
participant.UpdatedAt = nowUTC()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]byte, error) {
|
||||
room.mu.RLock()
|
||||
defer room.mu.RUnlock()
|
||||
|
||||
viewer, ok := room.Participants[viewerParticipantID]
|
||||
if !ok {
|
||||
return nil, ErrParticipantNotFound
|
||||
}
|
||||
|
||||
participants := make([]PublicParticipant, 0, len(room.Participants))
|
||||
for _, participant := range sortParticipants(room.Participants) {
|
||||
public := PublicParticipant{
|
||||
ID: participant.ID,
|
||||
Username: participant.Username,
|
||||
Role: participant.Role,
|
||||
IsAdmin: participant.IsAdmin,
|
||||
Connected: participant.Connected,
|
||||
HasVoted: participant.HasVoted,
|
||||
}
|
||||
|
||||
if room.Round.Revealed {
|
||||
public.VoteValue = participant.VoteValue
|
||||
} else if participant.ID == viewerParticipantID {
|
||||
public.VoteValue = participant.VoteValue
|
||||
}
|
||||
|
||||
participants = append(participants, public)
|
||||
}
|
||||
|
||||
state := PublicRoomState{
|
||||
RoomID: room.ID,
|
||||
RoomName: room.Settings.RoomName,
|
||||
Cards: append([]string(nil), room.Settings.Cards...),
|
||||
Revealed: room.Round.Revealed,
|
||||
RevealMode: room.Settings.RevealMode,
|
||||
MaxPeople: room.Settings.MaxPeople,
|
||||
AllowSpectators: room.Settings.AllowSpectators,
|
||||
AnonymousVoting: room.Settings.AnonymousVoting,
|
||||
AutoReset: room.Settings.AutoReset,
|
||||
VotingTimeoutSec: room.Settings.VotingTimeoutSec,
|
||||
Participants: participants,
|
||||
SelfParticipantID: viewerParticipantID,
|
||||
ViewerIsAdmin: viewer.IsAdmin,
|
||||
Links: RoomLinks{
|
||||
ParticipantLink: "/room/" + room.ID,
|
||||
},
|
||||
}
|
||||
|
||||
if viewer.IsAdmin {
|
||||
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastRoom(roomID string) {
|
||||
room, err := m.getRoom(roomID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type target struct {
|
||||
participantID string
|
||||
ch chan []byte
|
||||
}
|
||||
|
||||
room.mu.RLock()
|
||||
targets := make([]target, 0, len(room.subscribers))
|
||||
for _, subscriber := range room.subscribers {
|
||||
targets = append(targets, target{participantID: subscriber.participantID, ch: subscriber.ch})
|
||||
}
|
||||
room.mu.RUnlock()
|
||||
|
||||
for _, t := range targets {
|
||||
payload, marshalErr := m.marshalRoomState(room, t.participantID)
|
||||
if marshalErr != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case t.ch <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/state/persistence.go
Normal file
77
src/state/persistence.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type DiskStore struct {
|
||||
dataPath string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewDiskStore(dataPath string) (*DiskStore, error) {
|
||||
if err := os.MkdirAll(dataPath, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DiskStore{dataPath: dataPath}, nil
|
||||
}
|
||||
|
||||
func (ds *DiskStore) Save(room *Room) error {
|
||||
ds.mu.Lock()
|
||||
defer ds.mu.Unlock()
|
||||
|
||||
persisted := room.toPersisted()
|
||||
payload, err := json.MarshalIndent(persisted, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(ds.dataPath, room.ID+".json")
|
||||
tmpPath := finalPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, payload, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tmpPath, finalPath)
|
||||
}
|
||||
|
||||
func (ds *DiskStore) LoadAll() ([]persistedRoom, error) {
|
||||
ds.mu.Lock()
|
||||
defer ds.mu.Unlock()
|
||||
|
||||
entries, err := os.ReadDir(ds.dataPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rooms := make([]persistedRoom, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(ds.dataPath, entry.Name())
|
||||
payload, readErr := os.ReadFile(fullPath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var room persistedRoom
|
||||
if unmarshalErr := json.Unmarshal(payload, &room); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
|
||||
return rooms, nil
|
||||
}
|
||||
151
src/state/types.go
Normal file
151
src/state/types.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleParticipant = "participant"
|
||||
RoleViewer = "viewer"
|
||||
|
||||
RevealModeManual = "manual"
|
||||
RevealModeAutoAll = "all_voted"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRoomNotFound = errors.New("room not found")
|
||||
ErrParticipantNotFound = errors.New("participant not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrRoomFull = errors.New("room is full")
|
||||
ErrInvalidRole = errors.New("invalid role")
|
||||
ErrSpectatorsBlocked = errors.New("spectators are not allowed")
|
||||
ErrPasswordRequired = errors.New("password required or invalid")
|
||||
ErrInvalidCard = errors.New("invalid card")
|
||||
)
|
||||
|
||||
type RoomSettings struct {
|
||||
RoomName string `json:"roomName"`
|
||||
MaxPeople int `json:"maxPeople"`
|
||||
Cards []string `json:"cards"`
|
||||
AllowSpectators bool `json:"allowSpectators"`
|
||||
AnonymousVoting bool `json:"anonymousVoting"`
|
||||
AutoReset bool `json:"autoReset"`
|
||||
RevealMode string `json:"revealMode"`
|
||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Connected bool `json:"connected"`
|
||||
HasVoted bool `json:"hasVoted"`
|
||||
VoteValue string `json:"voteValue,omitempty"`
|
||||
JoinedAt time.Time `json:"joinedAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RoundState struct {
|
||||
Revealed bool `json:"revealed"`
|
||||
}
|
||||
|
||||
type persistedRoom struct {
|
||||
ID string `json:"id"`
|
||||
AdminToken string `json:"adminToken"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Settings RoomSettings `json:"settings"`
|
||||
Round RoundState `json:"round"`
|
||||
Participants []*Participant `json:"participants"`
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
participantID string
|
||||
ch chan []byte
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
ID string
|
||||
AdminToken string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Settings RoomSettings
|
||||
Round RoundState
|
||||
Participants map[string]*Participant
|
||||
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]*subscriber
|
||||
}
|
||||
|
||||
type CreateRoomInput struct {
|
||||
RoomName string
|
||||
CreatorUsername string
|
||||
MaxPeople int
|
||||
Cards []string
|
||||
AllowSpectators bool
|
||||
AnonymousVoting bool
|
||||
AutoReset bool
|
||||
RevealMode string
|
||||
VotingTimeoutSec int
|
||||
Password string
|
||||
}
|
||||
|
||||
type JoinRoomInput struct {
|
||||
ParticipantID string
|
||||
Username string
|
||||
Role string
|
||||
Password string
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
type CreateRoomResult struct {
|
||||
RoomID string `json:"roomId"`
|
||||
CreatorParticipantID string `json:"creatorParticipantId"`
|
||||
AdminToken string `json:"adminToken"`
|
||||
ParticipantLink string `json:"participantLink"`
|
||||
AdminLink string `json:"adminLink"`
|
||||
}
|
||||
|
||||
type JoinRoomResult struct {
|
||||
ParticipantID string `json:"participantId"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type PublicParticipant struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Connected bool `json:"connected"`
|
||||
HasVoted bool `json:"hasVoted"`
|
||||
VoteValue string `json:"voteValue,omitempty"`
|
||||
}
|
||||
|
||||
type RoomLinks struct {
|
||||
ParticipantLink string `json:"participantLink"`
|
||||
AdminLink string `json:"adminLink,omitempty"`
|
||||
}
|
||||
|
||||
type PublicRoomState struct {
|
||||
RoomID string `json:"roomId"`
|
||||
RoomName string `json:"roomName"`
|
||||
Cards []string `json:"cards"`
|
||||
Revealed bool `json:"revealed"`
|
||||
RevealMode string `json:"revealMode"`
|
||||
MaxPeople int `json:"maxPeople"`
|
||||
AllowSpectators bool `json:"allowSpectators"`
|
||||
AnonymousVoting bool `json:"anonymousVoting"`
|
||||
AutoReset bool `json:"autoReset"`
|
||||
VotingTimeoutSec int `json:"votingTimeoutSec"`
|
||||
Participants []PublicParticipant `json:"participants"`
|
||||
SelfParticipantID string `json:"selfParticipantId"`
|
||||
ViewerIsAdmin bool `json:"viewerIsAdmin"`
|
||||
Links RoomLinks `json:"links"`
|
||||
}
|
||||
83
src/state/utils.go
Normal file
83
src/state/utils.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func randomHex(bytes int) string {
|
||||
buf := make([]byte, bytes)
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func newUUIDv4() string {
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
buf[6] = (buf[6] & 0x0f) | 0x40
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16])
|
||||
}
|
||||
|
||||
func normalizeName(input string, max int) string {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
builder := strings.Builder{}
|
||||
for _, r := range trimmed {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
if unicode.IsPrint(r) {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
if builder.Len() >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func normalizeCard(input string) string {
|
||||
return normalizeName(input, 8)
|
||||
}
|
||||
|
||||
func hashPassword(password, salt string) string {
|
||||
h := sha256.Sum256([]byte(salt + ":" + password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func passwordMatches(password, salt, expectedHash string) bool {
|
||||
computed := hashPassword(password, salt)
|
||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1
|
||||
}
|
||||
|
||||
func nowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func sortParticipants(participants map[string]*Participant) []*Participant {
|
||||
list := make([]*Participant, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
list = append(list, participant)
|
||||
}
|
||||
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
if list[i].JoinedAt.Equal(list[j].JoinedAt) {
|
||||
return list[i].Username < list[j].Username
|
||||
}
|
||||
return list[i].JoinedAt.Before(list[j].JoinedAt)
|
||||
})
|
||||
|
||||
return list
|
||||
}
|
||||
Reference in New Issue
Block a user