This commit is contained in:
2026-03-05 22:08:06 +02:00
parent 22b700e241
commit 8f96d7514f
24 changed files with 2303 additions and 488 deletions

View File

@@ -3,7 +3,8 @@ package config
import "os"
type Config struct {
Port string
Port string
DataPath string
}
func Load() Config {
@@ -12,5 +13,13 @@ func Load() Config {
port = "8002"
}
return Config{Port: port}
dataPath := os.Getenv("DATA_PATH")
if dataPath == "" {
dataPath = "./data"
}
return Config{
Port: port,
DataPath: dataPath,
}
}

View File

@@ -1,20 +0,0 @@
package controllers
import (
"net/http"
"github.com/gin-gonic/gin"
"scrum-solitare/src/models"
)
type PageController struct{}
func NewPageController() *PageController {
return &PageController{}
}
func (pc *PageController) ShowRoomSetup(c *gin.Context) {
pageData := models.DefaultRoomSetupPageData()
c.HTML(http.StatusOK, "index.html", pageData)
}

26
src/handlers/pages.go Normal file
View File

@@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"scrum-solitare/src/models"
)
type PageHandler struct{}
func NewPageHandler() *PageHandler {
return &PageHandler{}
}
func (h *PageHandler) ShowConfigPage(c *gin.Context) {
pageData := models.DefaultRoomSetupPageData()
c.HTML(http.StatusOK, "index.html", pageData)
}
func (h *PageHandler) ShowRoomPage(c *gin.Context) {
c.HTML(http.StatusOK, "room.html", gin.H{
"RoomID": c.Param("roomID"),
})
}

243
src/handlers/room_api.go Normal file
View File

@@ -0,0 +1,243 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"scrum-solitare/src/state"
)
type RoomAPIHandler struct {
manager *state.Manager
}
func NewRoomAPIHandler(manager *state.Manager) *RoomAPIHandler {
return &RoomAPIHandler{manager: manager}
}
type createRoomRequest struct {
RoomName string `json:"roomName"`
CreatorUsername string `json:"creatorUsername"`
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"`
Password string `json:"password"`
}
type joinRoomRequest struct {
ParticipantID string `json:"participantId"`
Username string `json:"username"`
Role string `json:"role"`
Password string `json:"password"`
AdminToken string `json:"adminToken"`
}
type voteRequest struct {
ParticipantID string `json:"participantId"`
Card string `json:"card"`
}
type adminActionRequest struct {
ParticipantID string `json:"participantId"`
}
func (h *RoomAPIHandler) CreateRoom(c *gin.Context) {
var req createRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
result, err := h.manager.CreateRoom(state.CreateRoomInput{
RoomName: req.RoomName,
CreatorUsername: req.CreatorUsername,
MaxPeople: req.MaxPeople,
Cards: req.Cards,
AllowSpectators: req.AllowSpectators,
AnonymousVoting: req.AnonymousVoting,
AutoReset: req.AutoReset,
RevealMode: req.RevealMode,
VotingTimeoutSec: req.VotingTimeoutSec,
Password: req.Password,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create room"})
return
}
c.JSON(http.StatusCreated, result)
}
func (h *RoomAPIHandler) JoinRoom(c *gin.Context) {
var req joinRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{
ParticipantID: req.ParticipantID,
Username: req.Username,
Role: req.Role,
Password: req.Password,
AdminToken: req.AdminToken,
})
if err != nil {
h.writeStateError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *RoomAPIHandler) StreamEvents(c *gin.Context) {
roomID := c.Param("roomID")
participantID := c.Query("participantId")
if participantID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"})
return
}
stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID)
if err != nil {
h.writeStateError(c, err)
return
}
defer unsubscribe()
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
sendEvent := func(event string, payload []byte) error {
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", event); err != nil {
return err
}
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); err != nil {
return err
}
flusher.Flush()
return nil
}
if err := sendEvent("state", initial); err != nil {
return
}
pingTicker := time.NewTicker(20 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case payload, ok := <-stream:
if !ok {
return
}
if err := sendEvent("state", payload); err != nil {
return
}
case <-pingTicker.C:
if _, err := c.Writer.Write([]byte(": ping\n\n")); err != nil {
return
}
flusher.Flush()
}
}
}
func (h *RoomAPIHandler) CastVote(c *gin.Context) {
var req voteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.Card)
if err != nil {
h.writeStateError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *RoomAPIHandler) RevealVotes(c *gin.Context) {
h.handleAdminAction(c, h.manager.RevealVotes)
}
func (h *RoomAPIHandler) ResetVotes(c *gin.Context) {
h.handleAdminAction(c, h.manager.ResetVotes)
}
func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) {
var req adminActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID); err != nil {
h.writeStateError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string) error) {
var req adminActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
if err := fn(c.Param("roomID"), req.ParticipantID); err != nil {
h.writeStateError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) {
status := http.StatusInternalServerError
message := "internal error"
switch {
case errors.Is(err, state.ErrRoomNotFound):
status = http.StatusNotFound
message = err.Error()
case errors.Is(err, state.ErrParticipantNotFound):
status = http.StatusNotFound
message = err.Error()
case errors.Is(err, state.ErrUnauthorized):
status = http.StatusForbidden
message = err.Error()
case errors.Is(err, state.ErrRoomFull):
status = http.StatusConflict
message = err.Error()
case errors.Is(err, state.ErrPasswordRequired):
status = http.StatusUnauthorized
message = err.Error()
case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole):
status = http.StatusBadRequest
message = err.Error()
}
c.JSON(status, gin.H{"error": message})
}

View File

@@ -4,14 +4,22 @@ import (
"log"
"scrum-solitare/src/config"
"scrum-solitare/src/controllers"
"scrum-solitare/src/handlers"
"scrum-solitare/src/server"
"scrum-solitare/src/state"
)
func main() {
cfg := config.Load()
pageController := controllers.NewPageController()
router := server.NewRouter(pageController)
manager, err := state.NewManager(cfg.DataPath)
if err != nil {
log.Fatalf("failed to initialize state manager: %v", err)
}
pages := handlers.NewPageHandler()
rooms := handlers.NewRoomAPIHandler(manager)
router := server.NewRouter(pages, rooms)
if err := router.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed to start: %v", err)

View File

@@ -7,7 +7,6 @@ type RoomSetupPageData struct {
DefaultScale string
DefaultRevealMode string
DefaultVotingTime int
DefaultModerator string
AllowSpectators bool
AnonymousVoting bool
AutoResetCards bool
@@ -22,7 +21,6 @@ func DefaultRoomSetupPageData() RoomSetupPageData {
DefaultScale: "fibonacci",
DefaultRevealMode: "manual",
DefaultVotingTime: 0,
DefaultModerator: "creator",
AllowSpectators: true,
AnonymousVoting: true,
AutoResetCards: true,

View File

@@ -5,13 +5,14 @@ import (
"github.com/gin-gonic/gin"
"scrum-solitare/src/controllers"
"scrum-solitare/src/handlers"
"scrum-solitare/src/middleware"
)
func Register(r *gin.Engine, pageController *controllers.PageController) {
func Register(r *gin.Engine, pages *handlers.PageHandler, rooms *handlers.RoomAPIHandler) {
registerStatic(r)
registerPages(r, pageController)
registerPages(r, pages)
registerAPI(r, rooms)
}
func registerStatic(r *gin.Engine) {
@@ -20,6 +21,20 @@ func registerStatic(r *gin.Engine) {
static.StaticFS("/", http.Dir("static"))
}
func registerPages(r *gin.Engine, pageController *controllers.PageController) {
r.GET("/", pageController.ShowRoomSetup)
func registerPages(r *gin.Engine, pages *handlers.PageHandler) {
r.GET("/", pages.ShowConfigPage)
r.GET("/room/:roomID", pages.ShowRoomPage)
}
func registerAPI(r *gin.Engine, rooms *handlers.RoomAPIHandler) {
api := r.Group("/api")
{
api.POST("/rooms", rooms.CreateRoom)
api.POST("/rooms/:roomID/join", rooms.JoinRoom)
api.GET("/rooms/:roomID/events", rooms.StreamEvents)
api.POST("/rooms/:roomID/vote", rooms.CastVote)
api.POST("/rooms/:roomID/reveal", rooms.RevealVotes)
api.POST("/rooms/:roomID/reset", rooms.ResetVotes)
api.POST("/rooms/:roomID/leave", rooms.LeaveRoom)
}
}

View File

@@ -4,17 +4,17 @@ import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"scrum-solitare/src/controllers"
"scrum-solitare/src/handlers"
"scrum-solitare/src/routes"
)
func NewRouter(pageController *controllers.PageController) *gin.Engine {
func NewRouter(pages *handlers.PageHandler, rooms *handlers.RoomAPIHandler) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(gzip.Gzip(gzip.DefaultCompression))
r.LoadHTMLGlob("src/templates/*.html")
routes.Register(r, pageController)
routes.Register(r, pages, rooms)
return r
}

604
src/state/manager.go Normal file
View 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
View 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
View 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
View 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
}

View File

@@ -1,127 +0,0 @@
{{ define "body" }}
<section class="window config-window" aria-label="Room configuration">
<div class="title-bar">
<span>CreateRoom.exe</span>
<div class="title-bar-controls" aria-hidden="true">
<button type="button">_</button>
<button type="button"></button>
<button type="button">×</button>
</div>
</div>
<div class="window-content">
<p class="intro-copy">Configure your Scrum Poker room and share the invite link with your team.</p>
<form id="room-config-form" class="room-form" novalidate>
<div class="config-layout">
<section class="config-panel">
<div class="field-group">
<label for="room-name">Room name</label>
<input type="text" id="room-name" name="roomName" maxlength="40" value="{{ .DefaultRoomName }}" placeholder="Sprint 32 Planning" required>
</div>
<div class="field-row">
<div class="field-group">
<label for="username">Your username</label>
<input type="text" id="username" name="username" maxlength="32" value="{{ .DefaultUsername }}" placeholder="alice_dev" required>
</div>
<div class="field-group">
<label for="max-people">Max people</label>
<div class="number-input-wrap">
<input type="number" id="max-people" name="maxPeople" min="2" max="50" value="{{ .DefaultMaxPeople }}" required>
</div>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label for="estimation-scale">Estimation scale</label>
<select id="estimation-scale" name="estimationScale">
<option value="fibonacci" {{ if eq .DefaultScale "fibonacci" }}selected{{ end }}>Fibonacci (0,1,2,3,5,8,13,21,?)</option>
<option value="tshirt" {{ if eq .DefaultScale "tshirt" }}selected{{ end }}>T-Shirt (XS,S,M,L,XL,?)</option>
<option value="powers-of-two" {{ if eq .DefaultScale "powers-of-two" }}selected{{ end }}>Powers of 2 (1,2,4,8,16,32,?)</option>
</select>
</div>
<div class="field-group">
<label for="reveal-mode">Reveal mode</label>
<select id="reveal-mode" name="revealMode">
<option value="manual" {{ if eq .DefaultRevealMode "manual" }}selected{{ end }}>Manual reveal by moderator</option>
<option value="all-voted" {{ if eq .DefaultRevealMode "all-voted" }}selected{{ end }}>Auto reveal when everyone voted</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label for="voting-timeout">Voting timeout (seconds)</label>
<div class="number-input-wrap number-with-unit">
<input type="number" id="voting-timeout" name="votingTimeout" min="0" max="3600" value="{{ .DefaultVotingTime }}">
<span class="input-unit">sec</span>
</div>
</div>
<div class="field-group">
<label for="moderator">Moderator role</label>
<select id="moderator" name="moderatorRole">
<option value="creator" {{ if eq .DefaultModerator "creator" }}selected{{ end }}>Room creator is moderator</option>
<option value="none" {{ if eq .DefaultModerator "none" }}selected{{ end }}>No fixed moderator</option>
</select>
</div>
</div>
<fieldset class="window options-box">
<legend>Room options</legend>
<label class="option-item">
<input type="checkbox" id="allow-spectators" name="allowSpectators" {{ if .AllowSpectators }}checked{{ end }}>
<span>Allow spectators (non-voting viewers)</span>
</label>
<label class="option-item">
<input type="checkbox" id="anonymous-voting" name="anonymousVoting" {{ if .AnonymousVoting }}checked{{ end }}>
<span>Anonymous voting until reveal</span>
</label>
<label class="option-item">
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
<span>Auto-reset cards after each reveal</span>
</label>
</fieldset>
</section>
<aside class="window preview-window" aria-label="Room preview">
<div class="title-bar">
<span>Room Preview</span>
</div>
<div class="window-content preview-content">
<div class="preview-meta">
<span id="preview-scale">Scale: {{ .DefaultScale }}</span>
<span id="preview-max-people">Max: {{ .DefaultMaxPeople }}</span>
</div>
<div class="preview-board" id="preview-board">
<div class="preview-cards" id="preview-cards"></div>
</div>
<div class="card-editor">
<label for="custom-card">Add card</label>
<div class="card-editor-row">
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
<button type="button" id="add-card" class="btn">Add</button>
</div>
</div>
</div>
</aside>
</div>
<div class="status-line" id="config-status" role="status" aria-live="polite">
{{ .DefaultStatus }}
</div>
<div class="actions-row">
<button type="reset" class="btn">Reset</button>
<button type="submit" class="btn btn-primary">Create Room</button>
</div>
</form>
</div>
</section>
{{ end }}

View File

@@ -1,10 +0,0 @@
{{ define "footer" }}
</main>
<footer class="taskbar" aria-hidden="true">
<div class="taskbar-start">Start</div>
<div class="taskbar-status">Scrum Poker Setup</div>
</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
{{ end }}

View File

@@ -1,18 +0,0 @@
{{ define "header" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Retro Scrum Poker - Room Setup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<div class="top-bar">
<button class="btn" id="theme-toggle">Dark Mode</button>
</div>
<main id="desktop">
{{ end }}

View File

@@ -1,5 +1,144 @@
{{ define "index.html" }}
{{ template "header" . }}
{{ template "body" . }}
{{ template "footer" . }}
{{ end }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrum Poker - Room Configuration</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body data-page="config">
<div class="top-bar">
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
</div>
<main id="desktop">
<section class="window config-window" aria-label="Room configuration">
<div class="title-bar">
<span>CreateRoom.exe</span>
<div class="title-bar-controls" aria-hidden="true">
<button type="button">_</button>
<button type="button"></button>
<button type="button">×</button>
</div>
</div>
<div class="window-content">
<p class="intro-copy">Configure your Scrum Poker room and share the invite link with your team.</p>
<form id="room-config-form" class="room-form" novalidate>
<div class="config-layout">
<section class="config-panel">
<div class="field-group">
<label for="room-name">Room name</label>
<input type="text" id="room-name" name="roomName" maxlength="80" value="{{ .DefaultRoomName }}" placeholder="Sprint 32 Planning" required>
</div>
<div class="field-row">
<div class="field-group">
<label for="username">Your username</label>
<input type="text" id="username" name="username" maxlength="32" value="{{ .DefaultUsername }}" placeholder="alice_dev" required>
</div>
<div class="field-group">
<label for="max-people">Max people</label>
<div class="number-input-wrap">
<input type="number" id="max-people" name="maxPeople" min="2" max="50" value="{{ .DefaultMaxPeople }}" required>
</div>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label for="estimation-scale">Estimation scale</label>
<select id="estimation-scale" name="estimationScale">
<option value="fibonacci" {{ if eq .DefaultScale "fibonacci" }}selected{{ end }}>Fibonacci (0,1,2,3,5,8,13,21,?)</option>
<option value="tshirt" {{ if eq .DefaultScale "tshirt" }}selected{{ end }}>T-Shirt (XS,S,M,L,XL,?)</option>
<option value="powers-of-two" {{ if eq .DefaultScale "powers-of-two" }}selected{{ end }}>Powers of 2 (1,2,4,8,16,32,?)</option>
</select>
</div>
<div class="field-group">
<label for="reveal-mode">Reveal mode</label>
<select id="reveal-mode" name="revealMode">
<option value="manual" {{ if eq .DefaultRevealMode "manual" }}selected{{ end }}>Manual reveal by moderator</option>
<option value="all_voted" {{ if eq .DefaultRevealMode "all_voted" }}selected{{ end }}>Auto reveal when everyone voted</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label for="voting-timeout">Voting timeout (seconds)</label>
<div class="number-input-wrap number-with-unit">
<input type="number" id="voting-timeout" name="votingTimeoutSec" min="0" max="3600" value="{{ .DefaultVotingTime }}">
<span class="input-unit">sec</span>
</div>
</div>
<div class="field-group">
<label for="room-password">Room password (optional)</label>
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
</div>
</div>
<fieldset class="window options-box">
<legend>Room options</legend>
<label class="option-item">
<input type="checkbox" id="allow-spectators" name="allowSpectators" {{ if .AllowSpectators }}checked{{ end }}>
<span>Allow spectators (non-voting viewers)</span>
</label>
<label class="option-item">
<input type="checkbox" id="anonymous-voting" name="anonymousVoting" {{ if .AnonymousVoting }}checked{{ end }}>
<span>Anonymous voting until reveal</span>
</label>
<label class="option-item">
<input type="checkbox" id="auto-reset" name="autoReset" {{ if .AutoResetCards }}checked{{ end }}>
<span>Auto-reset cards after each reveal</span>
</label>
</fieldset>
</section>
<aside class="window preview-window" aria-label="Room preview">
<div class="title-bar">
<span>Room Preview</span>
</div>
<div class="window-content preview-content">
<div class="preview-meta">
<span id="preview-scale">Scale: {{ .DefaultScale }}</span>
<span id="preview-max-people">Max: {{ .DefaultMaxPeople }}</span>
</div>
<div class="preview-board" id="preview-board">
<div class="preview-cards" id="preview-cards"></div>
</div>
<p class="hint-text">Drag cards to reorder. Hover a card to remove it.</p>
<div class="card-editor">
<label for="custom-card">Add card</label>
<div class="card-editor-row">
<input type="text" id="custom-card" maxlength="8" placeholder="e.g. 34 or ?">
<button type="button" id="add-card" class="btn">Add</button>
</div>
</div>
</div>
</aside>
</div>
<div class="status-line" id="config-status" role="status" aria-live="polite">{{ .DefaultStatus }}</div>
<div class="actions-row">
<button type="reset" class="btn">Reset</button>
<button type="submit" class="btn btn-primary">Create Room</button>
</div>
</form>
</div>
</section>
</main>
<script src="/static/js/config.js"></script>
</body>
</html>

102
src/templates/room.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrum Poker Room</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body data-page="room" data-room-id="{{ .RoomID }}">
<div class="top-bar">
<button class="btn" id="theme-toggle" type="button">Dark Mode</button>
</div>
<main id="desktop" class="room-desktop">
<section class="room-grid" aria-label="Scrum poker room board">
<article class="window room-main-window">
<div class="title-bar">
<span id="room-title">Room</span>
<div class="title-bar-controls" aria-hidden="true">
<button type="button">_</button>
<button type="button"></button>
<button type="button">×</button>
</div>
</div>
<div class="window-content">
<div class="room-meta">
<span id="reveal-mode-label">Reveal mode: manual</span>
<span id="round-state-label">Cards hidden</span>
</div>
<div class="voting-board" id="voting-board"></div>
</div>
</article>
<aside class="window participants-window">
<div class="title-bar">
<span>Participants</span>
</div>
<div class="window-content participants-content">
<ul id="participant-list" class="participant-list"></ul>
</div>
</aside>
<section class="window control-window">
<div class="title-bar">
<span>Controls</span>
</div>
<div class="window-content control-content">
<div class="links-block">
<label>Participant Link</label>
<input id="participant-link" type="text" readonly>
<label>Admin Link</label>
<input id="admin-link" type="text" readonly>
</div>
<div id="admin-controls" class="admin-controls hidden">
<button type="button" id="reveal-btn" class="btn">Reveal</button>
<button type="button" id="reset-btn" class="btn">Reset</button>
</div>
<p id="room-status" class="status-line">Connecting...</p>
</div>
</section>
</section>
<section id="join-panel" class="window join-window hidden" aria-label="Join room">
<div class="title-bar">
<span>JoinRoom.exe</span>
</div>
<div class="window-content">
<form id="join-form" class="room-form" novalidate>
<div class="field-group">
<label for="join-username">Username</label>
<input id="join-username" name="username" type="text" maxlength="32" required>
</div>
<div class="field-group">
<label for="join-role">Role</label>
<select id="join-role" name="role">
<option value="participant">Participant</option>
<option value="viewer">Viewer</option>
</select>
</div>
<div class="field-group">
<label for="join-password">Room password (if required)</label>
<input id="join-password" name="password" type="password" maxlength="64">
</div>
<div class="field-group">
<label for="join-admin-token">Admin token (optional)</label>
<input id="join-admin-token" name="adminToken" type="text" maxlength="64">
</div>
<div class="actions-row">
<button type="submit" class="btn btn-primary">Join Room</button>
</div>
<p id="join-error" class="status-line hidden"></p>
</form>
</div>
</section>
</main>
<script src="/static/js/room.js"></script>
</body>
</html>