package main import ( "bytes" "crypto/rand" "database/sql" "encoding/hex" "fmt" "html/template" "log" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/ryanchen/bbq/db" ) const ( maxFieldLen = 200 maxDescLen = 5000 maxNoteLen = 500 maxSlots = 20 maxRsvps = 200 maxClaims = 50 maxMaxClaims = 50 ) func sanitize(s string, maxLen int) string { s = strings.TrimSpace(s) if len(s) > maxLen { s = s[:maxLen] } return s } func randomToken() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } func randomSlug() string { b := make([]byte, 4) rand.Read(b) return hex.EncodeToString(b) } // --- Home / Create Event --- func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { pageTmpl["home"].ExecuteTemplate(w, "layout", map[string]any{ "User": s.currentUser(r), "AuthEnabled": s.features.Auth, }) } func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 32*1024) r.ParseForm() title := sanitize(r.FormValue("title"), maxFieldLen) date := sanitize(r.FormValue("date"), maxFieldLen) time_ := sanitize(r.FormValue("time"), maxFieldLen) location := sanitize(r.FormValue("location"), maxFieldLen) description := sanitize(r.FormValue("description"), maxDescLen) if title == "" { http.Error(w, "Title is required", http.StatusBadRequest) return } slug := randomSlug() token := randomToken() event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description, }) if err != nil { log.Printf("create event: %v", err) http.Error(w, "Failed to create event", http.StatusInternalServerError) return } // Associate event with logged-in user if user := s.currentUser(r); user != nil { s.q.SetEventUser(r.Context(), db.SetEventUserParams{ UserID: sql.NullInt64{Int64: user.ID, Valid: true}, ID: event.ID, }) } slotNames := r.Form["slot_name"] slotEmojis := r.Form["slot_emoji"] slotMaxes := r.Form["slot_max"] created := 0 for i, name := range slotNames { if created >= maxSlots { break } name = sanitize(name, maxFieldLen) if name == "" { continue } emoji := "" if i < len(slotEmojis) { emoji = sanitize(slotEmojis[i], 32) } mc := int64(1) if i < len(slotMaxes) { if v, err := strconv.ParseInt(slotMaxes[i], 10, 64); err == nil && v > 0 { mc = v } } if mc > maxMaxClaims { mc = maxMaxClaims } s.q.CreateSlot(r.Context(), db.CreateSlotParams{ EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: mc, SortOrder: int64(i), }) created++ } http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther) } // --- Guest Event View --- type SlotView struct { Slot db.Slot Claims []db.Claim ClaimCount int64 IsFull bool Pct int64 } type GoingPerson struct { Name string Note string RsvpID int64 PlusOne int64 } type EventPageData struct { Event db.Event Slots []SlotView Rsvps []db.Rsvp GoingList []GoingPerson TotalGoing int64 IsAdmin bool BaseURL string DescriptionHTML template.HTML User *db.User AuthEnabled bool } func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil { return nil, err } slots, err := s.q.ListSlots(r.Context(), event.ID) if err != nil { return nil, err } claims, err := s.q.ListClaimsByEvent(r.Context(), event.ID) if err != nil { return nil, err } claimsBySlot := make(map[int64][]db.Claim) for _, c := range claims { claimsBySlot[c.SlotID] = append(claimsBySlot[c.SlotID], c) } var slotViews []SlotView var totalGoing int64 for _, slot := range slots { sc := claimsBySlot[slot.ID] count := int64(len(sc)) totalGoing += count pct := int64(0) if slot.MaxClaims > 0 { pct = (count * 100) / slot.MaxClaims if pct > 100 { pct = 100 } } slotViews = append(slotViews, SlotView{ Slot: slot, Claims: sc, ClaimCount: count, IsFull: count >= slot.MaxClaims, Pct: pct, }) } rsvps, err := s.q.ListRsvps(r.Context(), event.ID) if err != nil { return nil, err } for _, r := range rsvps { totalGoing += 1 + r.PlusOne } // Build deduplicated GoingList: RSVPs first, then claim-only people var goingList []GoingPerson seen := make(map[string]bool) for _, r := range rsvps { goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne}) seen[strings.ToLower(r.Name)] = true } for _, c := range claims { if !seen[strings.ToLower(c.Name)] { goingList = append(goingList, GoingPerson{Name: c.Name}) seen[strings.ToLower(c.Name)] = true } } var descHTML template.HTML if event.Description != "" { descHTML = RenderMarkdown(event.Description) } return &EventPageData{ Event: event, Slots: slotViews, Rsvps: rsvps, GoingList: goingList, TotalGoing: totalGoing, IsAdmin: isAdmin, BaseURL: s.baseURL, DescriptionHTML: descHTML, User: s.currentUser(r), AuthEnabled: s.features.Auth, }, nil } func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") data, err := s.loadEventPage(r, slug, false) if err != nil { if err == sql.ErrNoRows { http.NotFound(w, r) return } log.Printf("load event: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } pageTmpl["event"].ExecuteTemplate(w, "layout", data) } func (s *Server) handleSlotsPartial(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") data, err := s.loadEventPage(r, slug, false) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } // --- Unclaim --- func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") claimID, err := strconv.ParseInt(chi.URLParam(r, "claimID"), 10, 64) if err != nil { http.Error(w, "Invalid claim", http.StatusBadRequest) return } err = s.q.DeleteClaim(r.Context(), claimID) if err != nil { http.Error(w, "Failed to remove", http.StatusInternalServerError) return } s.notify(slug) data, err := s.loadEventPage(r, slug, false) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } // --- RSVP (+ optional claim) --- func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") r.Body = http.MaxBytesReader(w, r.Body, 8*1024) r.ParseForm() name := sanitize(r.FormValue("name"), maxFieldLen) if name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } note := sanitize(r.FormValue("note"), maxNoteLen) plusOne := int64(0) if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 { plusOne = v } if plusOne > 10 { plusOne = 10 } event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil { http.Error(w, "Event not found", http.StatusNotFound) return } // Optional slot claim if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" { slotID, err := strconv.ParseInt(slotIDStr, 10, 64) if err != nil { http.Error(w, "Invalid slot", http.StatusBadRequest) return } slot, err := s.q.GetSlot(r.Context(), slotID) if err != nil { http.Error(w, "Slot not found", http.StatusNotFound) return } if slot.EventID != event.ID { http.Error(w, "Invalid slot", http.StatusBadRequest) return } count, err := s.q.CountClaimsBySlot(r.Context(), slotID) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } if count >= slot.MaxClaims { http.Error(w, "Slot is full", http.StatusConflict) return } _, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{ SlotID: slotID, Name: name, Note: note, }) if err != nil { log.Printf("create claim: %v", err) http.Error(w, "Failed to claim", http.StatusInternalServerError) return } } // Create RSVP (deduped — skip if already on the list) _, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{ EventID: event.ID, Name: name, }) if err == sql.ErrNoRows { count, err := s.q.CountRsvps(r.Context(), event.ID) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } if count >= maxRsvps { http.Error(w, "RSVP list is full", http.StatusConflict) return } _, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{ EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, }) if err != nil { log.Printf("create rsvp: %v", err) http.Error(w, "Failed", http.StatusInternalServerError) return } } s.notify(slug) data, err := s.loadEventPage(r, slug, false) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } func (s *Server) handleEditRsvpForm(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64) if err != nil { http.Error(w, "Invalid RSVP", http.StatusBadRequest) return } rsvp, err := s.q.GetRsvp(r.Context(), rsvpID) if err != nil { http.Error(w, "RSVP not found", http.StatusNotFound) return } pageTmpl["edit-rsvp"].ExecuteTemplate(w, "edit-rsvp", map[string]any{ "Slug": slug, "Rsvp": rsvp, }) } func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64) if err != nil { http.Error(w, "Invalid RSVP", http.StatusBadRequest) return } r.Body = http.MaxBytesReader(w, r.Body, 8*1024) r.ParseForm() name := sanitize(r.FormValue("name"), maxFieldLen) if name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } note := sanitize(r.FormValue("note"), maxNoteLen) plusOne := int64(0) if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 { plusOne = v } if plusOne > 10 { plusOne = 10 } err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{ Name: name, Note: note, PlusOne: plusOne, ID: rsvpID, }) if err != nil { http.Error(w, "Failed to update", http.StatusInternalServerError) return } s.notify(slug) w.Header().Set("HX-Trigger", "closeModal") data, err := s.loadEventPage(r, slug, false) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64) if err != nil { http.Error(w, "Invalid RSVP", http.StatusBadRequest) return } s.q.DeleteRsvp(r.Context(), rsvpID) s.notify(slug) data, err := s.loadEventPage(r, slug, false) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } // --- SSE --- func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ch := s.subscribe(slug) defer s.unsubscribe(slug, ch) for { select { case <-ch: data, err := s.loadEventPage(r, slug, false) if err != nil { return } var buf bytes.Buffer pageTmpl["slots"].ExecuteTemplate(&buf, "slots-inner", data) // SSE format: replace newlines for event stream lines := strings.Split(buf.String(), "\n") for _, line := range lines { fmt.Fprintf(w, "data: %s\n", line) } fmt.Fprintf(w, "\n") flusher.Flush() case <-r.Context().Done(): return } } } // --- Admin --- // authorizeAdmin checks the admin token and (when auth is enabled) that the // logged-in user owns the event. Returns the event on success, nil on failure. // For page loads (isPage=true) it redirects to the guest view; for actions it // returns 403. func (s *Server) authorizeAdmin(w http.ResponseWriter, r *http.Request, isPage bool) *db.Event { slug := chi.URLParam(r, "slug") token := chi.URLParam(r, "token") event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil || event.AdminToken != token { http.NotFound(w, r) return nil } if s.features.Auth && event.UserID.Valid { user := s.currentUser(r) if user == nil || user.ID != event.UserID.Int64 { if isPage { http.Redirect(w, r, "/e/"+slug, http.StatusSeeOther) } else { http.Error(w, "Forbidden", http.StatusForbidden) } return nil } } return &event } func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { event := s.authorizeAdmin(w, r, true) if event == nil { return } data, err := s.loadEventPage(r, event.Slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["event"].ExecuteTemplate(w, "layout", data) } func (s *Server) handleUpdateDescription(w http.ResponseWriter, r *http.Request) { event := s.authorizeAdmin(w, r, false) if event == nil { return } r.Body = http.MaxBytesReader(w, r.Body, 32*1024) r.ParseForm() description := sanitize(r.FormValue("description"), maxDescLen) s.q.UpdateEventDescription(r.Context(), db.UpdateEventDescriptionParams{ Description: description, ID: event.ID, }) http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther) } func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) { event := s.authorizeAdmin(w, r, false) if event == nil { return } r.Body = http.MaxBytesReader(w, r.Body, 8*1024) r.ParseForm() name := sanitize(r.FormValue("name"), maxFieldLen) emoji := sanitize(r.FormValue("emoji"), 32) mc := int64(1) if v, err := strconv.ParseInt(r.FormValue("max_claims"), 10, 64); err == nil && v > 0 { mc = v } if mc > maxMaxClaims { mc = maxMaxClaims } if name == "" { http.Error(w, "Name required", http.StatusBadRequest) return } slots, _ := s.q.ListSlots(r.Context(), event.ID) if len(slots) >= maxSlots { http.Error(w, "Too many slots", http.StatusConflict) return } _, err := s.q.CreateSlot(r.Context(), db.CreateSlotParams{ EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: mc, SortOrder: 999, }) if err != nil { http.Error(w, "Failed", http.StatusInternalServerError) return } s.notify(event.Slug) data, err := s.loadEventPage(r, event.Slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } func (s *Server) handleDeleteSlot(w http.ResponseWriter, r *http.Request) { event := s.authorizeAdmin(w, r, false) if event == nil { return } slotID, err := strconv.ParseInt(chi.URLParam(r, "slotID"), 10, 64) if err != nil { http.Error(w, "Invalid slot", http.StatusBadRequest) return } s.q.DeleteSlot(r.Context(), slotID) s.notify(event.Slug) data, err := s.loadEventPage(r, event.Slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) }