Add attendee cap with required login for capped events

Hosts can optionally cap total attendance (RSVPs + plus-ones) when
creating an event. Capped events require guests to log in via the
existing email code flow; RSVPs are tied to accounts and deduped per
user. The event page shows spots left and replaces the form with a
full notice when the cap is reached; the server rejects over-cap
RSVPs and edits with 409 as a race backstop.

Also threads a safeNext-validated ?next= param through the login and
name-setup flow so guests land back on the event after signing in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:36:57 -04:00
parent 2aa3abf035
commit d005080aca
13 changed files with 368 additions and 44 deletions
+51 -6
View File
@@ -11,6 +11,7 @@ import (
"log"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -63,39 +64,62 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.currentUser(r) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
dest := "/login"
if r.Method == http.MethodGet {
dest += "?next=" + url.QueryEscape(r.URL.RequestURI())
}
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// safeNext returns next if it is a safe local path, else "".
func safeNext(next string) string {
if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") && !strings.ContainsAny(next, "\\\r\n") {
return next
}
return ""
}
// isEmail returns true if the input looks like an email address.
func isEmail(s string) bool {
return strings.Contains(s, "@")
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.URL.Query().Get("next"))
if s.currentUser(r) != nil {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
dest := "/dashboard"
if next != "" {
dest = next
}
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "identify",
"Next": next,
"AuthEnabled": true,
})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
loginURL := "/login"
if next != "" {
loginURL += "?next=" + url.QueryEscape(next)
}
raw := strings.TrimSpace(r.FormValue("identifier"))
if raw == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
http.Redirect(w, r, loginURL, http.StatusSeeOther)
return
}
identifier := strings.ToLower(raw)
if !isEmail(identifier) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
http.Redirect(w, r, loginURL, http.StatusSeeOther)
return
}
@@ -118,11 +142,13 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code",
"Identifier": identifier,
"Next": next,
"AuthEnabled": true,
})
}
func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
identifier := strings.TrimSpace(r.FormValue("identifier"))
code := strings.TrimSpace(r.FormValue("code"))
@@ -134,6 +160,7 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code",
"Identifier": identifier,
"Next": next,
"Error": "Invalid or expired code. Try again.",
"AuthEnabled": true,
})
@@ -174,10 +201,18 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
// If user has no name, send them to set it
if user.Name == "" {
http.Redirect(w, r, "/account/name", http.StatusSeeOther)
dest := "/account/name"
if next != "" {
dest += "?next=" + url.QueryEscape(next)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
@@ -215,21 +250,31 @@ func (s *Server) handleNamePage(w http.ResponseWriter, r *http.Request) {
user := s.currentUser(r)
pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{
"User": user,
"Next": safeNext(r.URL.Query().Get("next")),
"AuthEnabled": true,
})
}
func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) {
user := s.currentUser(r)
next := safeNext(r.FormValue("next"))
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Redirect(w, r, "/account/name", http.StatusSeeOther)
dest := "/account/name"
if next != "" {
dest += "?next=" + url.QueryEscape(next)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{
Name: name,
ID: user.ID,
})
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}