diff --git a/.gitignore b/.gitignore index 3e1c7cb..139ceac 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Thumbs.db # Environment .env .env.* + + +# Data +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e02b094..2f3a025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,8 @@ COPY --from=build /out/scrum-solitare /app/scrum-solitare COPY --from=build /app/src/templates /app/src/templates COPY --from=build /app/static /app/static +RUN mkdir -p /app/data && chown -R app:app /app + EXPOSE 8002 ENV PORT=8002 diff --git a/README.md b/README.md index 4441bc6..b560c77 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,40 @@ # Scrum Solitare -Win98-themed Scrum Poker web app scaffold using Go + Gin. +Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time room updates. -## Features +## Highlights -- Gin server with default port `8002` -- Gzip compression enabled -- Cache headers for static `css`, `js`, and image assets -- Template rendering from `src/templates` -- Static file hosting from `static/` -- `/` currently serves a room configuration page (UI only) +- Go backend with layered architecture (`handlers`, `state`, `routes`, `config`) +- Memory-first room state with disk synchronization to JSON files +- Real-time updates via Server-Sent Events (SSE) +- Strict state sanitization: unrevealed votes from other users are never broadcast +- Backend authorization for admin-only actions (`reveal`, `reset`) +- Win98-themed frontend with: + - Config page and card deck preview + - Drag-and-drop card ordering + - Card add/remove with animation completion handling + - Room password option + - Room interface with voting area, participants, and admin controls +- Username persistence through `localStorage` ## Project Layout -- `src/main.go`: Application bootstrap -- `src/config/`: Environment and runtime configuration -- `src/server/`: Gin engine construction and middleware wiring -- `src/routes/`: Route registration -- `src/controllers/`: HTTP handlers/controllers -- `src/middleware/`: Custom Gin middleware -- `src/models/`: Page/view data models -- `src/templates/`: HTML templates (`header`, `body`, `footer`, and `index` composition) -- `static/css/`: Stylesheets -- `static/js/`: Frontend scripts -- `static/img/`: Image assets +- `src/main.go`: app bootstrap +- `src/config/`: environment configuration +- `src/server/`: Gin engine setup +- `src/routes/`: page/api route registration +- `src/handlers/`: HTTP handlers (pages + API + SSE) +- `src/state/`: in-memory room manager, sanitization, persistence +- `src/middleware/`: static cache headers middleware +- `src/models/`: template page data models +- `src/templates/`: HTML templates (`index.html`, `room.html`) +- `static/css/`: styles +- `static/js/`: frontend logic (`config.js`, `room.js`) + +## Environment Variables + +- `PORT`: server port (default `8002`) +- `DATA_PATH`: directory for room JSON files (default `./data`) ## Run Locally @@ -34,22 +45,27 @@ go run ./src Open `http://localhost:8002`. -## Environment Variables - -- `PORT`: Optional server port override (default is `8002`) - ## Docker -Build image: +Build: ```bash docker build -t scrum-solitare . ``` -Run container: +Run: ```bash -docker run --rm -p 8002:8002 scrum-solitare +docker run --rm -p 8002:8002 -e DATA_PATH=/app/data scrum-solitare ``` -Then open `http://localhost:8002`. +## Real-Time Flow (SSE) + +- Client joins room via `POST /api/rooms/:roomID/join` +- Client subscribes to `GET /api/rooms/:roomID/events?participantId=...` +- Server broadcasts sanitized room state updates on critical mutations: + - room creation + - join/leave + - vote cast + - reveal + - reset diff --git a/src/config/config.go b/src/config/config.go index f05717c..96009a9 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -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, + } } diff --git a/src/controllers/page_controller.go b/src/controllers/page_controller.go deleted file mode 100644 index b0a00f2..0000000 --- a/src/controllers/page_controller.go +++ /dev/null @@ -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) -} diff --git a/src/handlers/pages.go b/src/handlers/pages.go new file mode 100644 index 0000000..fc8be3b --- /dev/null +++ b/src/handlers/pages.go @@ -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"), + }) +} diff --git a/src/handlers/room_api.go b/src/handlers/room_api.go new file mode 100644 index 0000000..3f2fe72 --- /dev/null +++ b/src/handlers/room_api.go @@ -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}) +} diff --git a/src/main.go b/src/main.go index 0fdf2e3..b5a35de 100644 --- a/src/main.go +++ b/src/main.go @@ -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) diff --git a/src/models/room_setup.go b/src/models/room_setup.go index 9918c9a..e04a0d5 100644 --- a/src/models/room_setup.go +++ b/src/models/room_setup.go @@ -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, diff --git a/src/routes/routes.go b/src/routes/routes.go index 0cd938d..c57dfcc 100644 --- a/src/routes/routes.go +++ b/src/routes/routes.go @@ -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) + } } diff --git a/src/server/router.go b/src/server/router.go index 87a68ae..c26bf0b 100644 --- a/src/server/router.go +++ b/src/server/router.go @@ -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 } diff --git a/src/state/manager.go b/src/state/manager.go new file mode 100644 index 0000000..30570eb --- /dev/null +++ b/src/state/manager.go @@ -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: + } + } +} diff --git a/src/state/persistence.go b/src/state/persistence.go new file mode 100644 index 0000000..b4bc90f --- /dev/null +++ b/src/state/persistence.go @@ -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 +} diff --git a/src/state/types.go b/src/state/types.go new file mode 100644 index 0000000..75ce695 --- /dev/null +++ b/src/state/types.go @@ -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"` +} diff --git a/src/state/utils.go b/src/state/utils.go new file mode 100644 index 0000000..abbf287 --- /dev/null +++ b/src/state/utils.go @@ -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 +} diff --git a/src/templates/body-config.html b/src/templates/body-config.html deleted file mode 100644 index 87c53b2..0000000 --- a/src/templates/body-config.html +++ /dev/null @@ -1,127 +0,0 @@ -{{ define "body" }} - - - CreateRoom.exe - - _ - □ - × - - - - - Configure your Scrum Poker room and share the invite link with your team. - - - - - - Room name - - - - - - Your username - - - - - Max people - - - - - - - - - Estimation scale - - Fibonacci (0,1,2,3,5,8,13,21,?) - T-Shirt (XS,S,M,L,XL,?) - Powers of 2 (1,2,4,8,16,32,?) - - - - - Reveal mode - - Manual reveal by moderator - Auto reveal when everyone voted - - - - - - - Voting timeout (seconds) - - - sec - - - - - Moderator role - - Room creator is moderator - No fixed moderator - - - - - - Room options - - - Allow spectators (non-voting viewers) - - - - Anonymous voting until reveal - - - - Auto-reset cards after each reveal - - - - - - - - - {{ .DefaultStatus }} - - - - Reset - Create Room - - - - -{{ end }} diff --git a/src/templates/footer.html b/src/templates/footer.html deleted file mode 100644 index 25c7ac7..0000000 --- a/src/templates/footer.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "footer" }} - - - -
Configure your Scrum Poker room and share the invite link with your team.