003a320690
QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.
Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
arrival_count, notes, walk_in). UNIQUE on guest_id is the
double-check-in guard at the DB layer; signature validation lives
in the QR JWT.
QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
guest_id, exp} payloads with the platform's existing HMAC secret.
Issue() extends expiry to eventDate+24h so a QR minted weeks in
advance still scans on the day. Parse() distinguishes
ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
detection, and short-secret refusal at construction time.
Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
unique violation), ListByEvent, Summary (arrived headcount,
expected headcount, guests-checked-in count), GuestBelongsToEvent
(belt-and-braces guard against a forged JWT pointing at a
different event's guest).
API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
refuses cross-event payloads (400), refuses expired ones (410),
records the row, broadcasts check_in.recorded over the existing
WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
summary together so the dashboard widget hydrates in one call.
Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
a progress bar), recent-arrivals list, Walk-in button, and a
"Start scanning" button that opens a full-screen camera modal.
jsQR loaded from CDN on first open (no bundler dep). Scan
throttling + dedupe prevents the 30fps camera loop from POSTing
N times per paper QR. Successful scan vibrates the phone.
Duplicate (409) → "Already checked in" toast; expired (410) →
"This code has expired"; foreign-event (400) → "doesn't look
like one of your guests".
- New "Check-in" tab on the event-detail page, between
Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
the day" / "Your door code" QR block for attending guests. The
PNG ships pre-rendered from the API so the frontend doesn't need
its own QR library.
- The submit flow now refetches /access after a successful POST so
the QR appears immediately on first submit, not just on revisit.
Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
409, summary reflects arrival), TestCheckInRejectsForeignQR
(event A's JWT can't be used on event B), TestWalkInCreatesGuest
AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).
Tier 2 is complete: Blocks A through H all shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
577 lines
22 KiB
Go
577 lines
22 KiB
Go
package api
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/alchemistkay/guestguard/internal/auth"
|
|
"github.com/alchemistkay/guestguard/internal/billing"
|
|
"github.com/alchemistkay/guestguard/internal/notification"
|
|
"github.com/alchemistkay/guestguard/internal/ratelimit"
|
|
"github.com/alchemistkay/guestguard/internal/storage"
|
|
"github.com/alchemistkay/guestguard/internal/uploads"
|
|
)
|
|
|
|
type Server struct {
|
|
logger *slog.Logger
|
|
db *storage.DB
|
|
hub *Hub
|
|
authH *authHandler
|
|
me *meHandler
|
|
events *eventHandler
|
|
guests *guestHandler
|
|
tokens *tokenHandler
|
|
rsvps *rsvpHandler
|
|
activity *activityHandler
|
|
ws *wsHandler
|
|
wsTicket *wsTicketHandler
|
|
health *healthHandler
|
|
signer *auth.JWTSigner
|
|
limiter *ratelimit.Limiter
|
|
unsub *unsubscribeHandler
|
|
webhooks *webhookHandler
|
|
csv *csvImportHandler
|
|
billing *billingHandler
|
|
stripeWH *stripeWebhookHandler
|
|
privacy *privacyHandler
|
|
collabs *collaboratorHandler
|
|
analytics *analyticsHandler
|
|
branding *brandingHandler
|
|
uploads *uploadHandler
|
|
security *securityHandler
|
|
messages *messageHandler
|
|
checkIns *checkInHandler
|
|
}
|
|
|
|
type ServerDeps struct {
|
|
Logger *slog.Logger
|
|
DB *storage.DB
|
|
Hub *Hub
|
|
AccessPublisher accessPublisher
|
|
RSVPPublisher rsvpPublisher
|
|
InvitationPublisher invitationPublisher
|
|
FraudScorer fraudScorer
|
|
TokenTTL time.Duration
|
|
|
|
// Auth
|
|
JWTSecret string
|
|
JWTIssuer string
|
|
AccessTokenTTL time.Duration
|
|
RefreshTokenTTL time.Duration
|
|
EmailVerificationTTL time.Duration
|
|
PasswordResetTTL time.Duration
|
|
PublicBaseURL string
|
|
RefreshCookieDomain string
|
|
RefreshCookieSecure bool
|
|
EmailSender auth.EmailSender
|
|
WSTicketTTL time.Duration
|
|
|
|
// Rate limiting / abuse controls
|
|
Redis *redis.Client
|
|
LoginLockoutMax int // failed attempts before account lockout (default 5)
|
|
LoginFailWindow time.Duration // counter TTL (default 15 min)
|
|
|
|
// Notifications / unsubscribe
|
|
NotificationRepo *notification.Repo
|
|
SuppressionRepo *notification.SuppressionRepo
|
|
UnsubscribeSigner *notification.UnsubscribeSigner
|
|
|
|
// Billing (Block F). Nil StripeClient leaves billing disabled — the
|
|
// system still boots and runs, all users sit on the free tier with
|
|
// its limits enforced; /billing/* returns 503.
|
|
StripeClient *billing.Client
|
|
|
|
// Uploads (Tier 2 Block D). UploadsDir is where the LocalFSStore
|
|
// writes images; UploadsPublicURL is the base prefix the API will
|
|
// serve them under. Both empty means uploads are disabled (POST
|
|
// /uploads/image returns 503).
|
|
UploadsDir string
|
|
UploadsPublicURL string
|
|
}
|
|
|
|
func NewServer(deps ServerDeps) (*Server, error) {
|
|
eventRepo := storage.NewEventRepo(deps.DB)
|
|
guestRepo := storage.NewGuestRepo(deps.DB)
|
|
tokenRepo := storage.NewTokenRepo(deps.DB)
|
|
rsvpRepo := storage.NewRSVPRepo(deps.DB)
|
|
accessRepo := storage.NewAccessLogRepo(deps.DB)
|
|
userRepo := storage.NewUserRepo(deps.DB)
|
|
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
|
inviteRepo := storage.NewInviteRepo(deps.DB)
|
|
analyticsRepo := storage.NewAnalyticsRepo(deps.DB)
|
|
brandingRepo := storage.NewBrandingRepo(deps.DB)
|
|
allowlistRepo := storage.NewAllowlistRepo(deps.DB)
|
|
editNonces := newEditNonceStore(deps.Redis)
|
|
messageRepo := storage.NewMessageRepo(deps.DB)
|
|
checkInRepo := storage.NewCheckInRepo(deps.DB)
|
|
|
|
// Tier 2 Block H — QR JWT signer reuses the platform's JWT secret
|
|
// so production secrets management already covers it. TTL=6h is the
|
|
// minimum lifetime; Issue() extends to eventDate+24h on demand so
|
|
// codes minted weeks in advance still scan on the day.
|
|
checkInQRSigner, err := auth.NewCheckInQRSigner(deps.JWTSecret, deps.JWTIssuer, 6*time.Hour)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
feedbackRepo := storage.NewFeedbackRepo(deps.DB)
|
|
|
|
// Branding image store. Empty UploadsDir leaves it nil and the upload
|
|
// + serve handlers report 503, so the rest of the service keeps
|
|
// working in stripped-down environments.
|
|
var imageStore uploads.ImageStore
|
|
if deps.UploadsDir != "" {
|
|
imageStore = &uploads.LocalFSStore{
|
|
Dir: deps.UploadsDir,
|
|
PublicBase: deps.UploadsPublicURL,
|
|
}
|
|
}
|
|
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
|
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
|
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
|
subRepo := storage.NewSubscriptionRepo(deps.DB)
|
|
enforcer := newTierEnforcer(subRepo, deps.PublicBaseURL)
|
|
|
|
signer, err := auth.NewJWTSigner(deps.JWTSecret, deps.AccessTokenTTL, deps.JWTIssuer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hasher := auth.NewPasswordHasher()
|
|
|
|
emails := deps.EmailSender
|
|
if emails == nil {
|
|
emails = auth.LogEmailSender{Logger: deps.Logger}
|
|
}
|
|
|
|
hub := deps.Hub
|
|
if hub == nil {
|
|
hub = NewHub(deps.Logger)
|
|
}
|
|
|
|
wsTicketTTL := deps.WSTicketTTL
|
|
if wsTicketTTL <= 0 {
|
|
wsTicketTTL = 60 * time.Second
|
|
}
|
|
wsTickets := newWSTicketStore(wsTicketTTL)
|
|
|
|
var limiter *ratelimit.Limiter
|
|
var lockout *auth.LockoutTracker
|
|
if deps.Redis != nil {
|
|
limiter = ratelimit.New(deps.Redis, "gg:rl")
|
|
lockoutMax := deps.LoginLockoutMax
|
|
if lockoutMax <= 0 {
|
|
lockoutMax = 5
|
|
}
|
|
failWindow := deps.LoginFailWindow
|
|
if failWindow <= 0 {
|
|
failWindow = 15 * time.Minute
|
|
}
|
|
lockout = auth.NewLockoutTracker(deps.Redis, lockoutMax, failWindow)
|
|
}
|
|
|
|
authH := newAuthHandler(authHandlerDeps{
|
|
Logger: deps.Logger,
|
|
Users: userRepo,
|
|
Verifications: verifRepo,
|
|
Resets: resetRepo,
|
|
Refreshes: refreshRepo,
|
|
Hasher: hasher,
|
|
Signer: signer,
|
|
Emails: emails,
|
|
Lockout: lockout,
|
|
Limiter: limiter,
|
|
PublicBaseURL: deps.PublicBaseURL,
|
|
EmailVerificationTTL: deps.EmailVerificationTTL,
|
|
PasswordResetTTL: deps.PasswordResetTTL,
|
|
RefreshTTL: deps.RefreshTokenTTL,
|
|
CookieDomain: deps.RefreshCookieDomain,
|
|
CookieSecure: deps.RefreshCookieSecure,
|
|
})
|
|
|
|
return &Server{
|
|
logger: deps.Logger,
|
|
db: deps.DB,
|
|
hub: hub,
|
|
authH: authH,
|
|
me: &meHandler{users: userRepo},
|
|
events: &eventHandler{repo: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
|
guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
|
tokens: &tokenHandler{
|
|
logger: deps.Logger,
|
|
guests: guestRepo,
|
|
tokens: tokenRepo,
|
|
events: eventRepo,
|
|
users: userRepo,
|
|
accessLogs: accessRepo,
|
|
rsvps: rsvpRepo,
|
|
collabs: collabRepo,
|
|
branding: brandingRepo,
|
|
editNonces: editNonces,
|
|
emails: emails,
|
|
checkInQR: checkInQRSigner,
|
|
gen: auth.NewGenerator(),
|
|
ttl: deps.TokenTTL,
|
|
pub: deps.AccessPublisher,
|
|
invitations: deps.InvitationPublisher,
|
|
publicBaseURL: deps.PublicBaseURL,
|
|
},
|
|
rsvps: &rsvpHandler{
|
|
logger: deps.Logger,
|
|
guests: guestRepo,
|
|
tokens: tokenRepo,
|
|
events: eventRepo,
|
|
rsvps: rsvpRepo,
|
|
accessLogs: accessRepo,
|
|
allowlist: allowlistRepo,
|
|
editNonces: editNonces,
|
|
scorer: deps.FraudScorer,
|
|
pub: deps.RSVPPublisher,
|
|
},
|
|
activity: &activityHandler{
|
|
events: eventRepo,
|
|
collabs: collabRepo,
|
|
rsvps: rsvpRepo,
|
|
accessLogs: accessRepo,
|
|
},
|
|
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
|
|
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo},
|
|
health: &healthHandler{pool: deps.DB.Pool},
|
|
signer: signer,
|
|
limiter: limiter,
|
|
unsub: &unsubscribeHandler{
|
|
logger: deps.Logger,
|
|
signer: deps.UnsubscribeSigner,
|
|
suppress: deps.SuppressionRepo,
|
|
},
|
|
webhooks: &webhookHandler{
|
|
logger: deps.Logger,
|
|
notifs: deps.NotificationRepo,
|
|
suppress: deps.SuppressionRepo,
|
|
},
|
|
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
|
billing: &billingHandler{
|
|
logger: deps.Logger,
|
|
stripe: deps.StripeClient,
|
|
users: userRepo,
|
|
subscriptions: subRepo,
|
|
publicBaseURL: deps.PublicBaseURL,
|
|
},
|
|
stripeWH: &stripeWebhookHandler{
|
|
logger: deps.Logger,
|
|
stripe: deps.StripeClient,
|
|
subs: subRepo,
|
|
},
|
|
analytics: &analyticsHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
collabs: collabRepo,
|
|
repo: analyticsRepo,
|
|
redis: deps.Redis,
|
|
},
|
|
branding: &brandingHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
collabs: collabRepo,
|
|
repo: brandingRepo,
|
|
store: imageStore,
|
|
},
|
|
uploads: &uploadHandler{
|
|
logger: deps.Logger,
|
|
store: imageStore,
|
|
},
|
|
security: &securityHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
collabs: collabRepo,
|
|
allowlist: allowlistRepo,
|
|
feedback: feedbackRepo,
|
|
access: accessRepo,
|
|
},
|
|
messages: &messageHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
collabs: collabRepo,
|
|
repo: messageRepo,
|
|
},
|
|
checkIns: &checkInHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
guests: guestRepo,
|
|
collabs: collabRepo,
|
|
repo: checkInRepo,
|
|
qrSigner: checkInQRSigner,
|
|
hub: hub,
|
|
},
|
|
collabs: &collaboratorHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
users: userRepo,
|
|
collabs: collabRepo,
|
|
invites: inviteRepo,
|
|
emails: emails,
|
|
publicBaseURL: deps.PublicBaseURL,
|
|
},
|
|
privacy: &privacyHandler{
|
|
logger: deps.Logger,
|
|
users: userRepo,
|
|
events: eventRepo,
|
|
guests: guestRepo,
|
|
tokens: tokenRepo,
|
|
rsvps: rsvpRepo,
|
|
access: accessRepo,
|
|
notifs: deps.DB,
|
|
refresh: refreshRepo,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Hub() *Hub { return s.hub }
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /health", s.health.live)
|
|
mux.HandleFunc("GET /health/ready", s.health.ready)
|
|
|
|
// Per-route rate limiters (no-op when Redis isn't wired).
|
|
authed := requireAuth(s.signer)
|
|
rl := func(name string, limit int, window time.Duration, keyFn KeyFunc, h http.Handler) http.Handler {
|
|
if s.limiter == nil {
|
|
return h
|
|
}
|
|
return s.limiter.Middleware(
|
|
ratelimit.Rule{Name: name, Limit: limit, Window: window},
|
|
keyFn,
|
|
s.logger,
|
|
)(h)
|
|
}
|
|
|
|
// Anonymous auth endpoints — POST /auth/login + /auth/forgot-password
|
|
// rate-limit inside the handler (key includes the email body field).
|
|
mux.Handle("POST /auth/signup",
|
|
rl("auth_signup", 5, time.Hour, ipKey, http.HandlerFunc(s.authH.signup)))
|
|
mux.HandleFunc("POST /auth/login", s.authH.login)
|
|
mux.HandleFunc("POST /auth/refresh", s.authH.refresh)
|
|
mux.HandleFunc("POST /auth/logout", s.authH.logout)
|
|
mux.HandleFunc("POST /auth/verify-email", s.authH.verifyEmail)
|
|
mux.HandleFunc("POST /auth/forgot-password", s.authH.forgotPassword)
|
|
mux.HandleFunc("POST /auth/reset-password", s.authH.resetPassword)
|
|
|
|
mux.Handle("GET /me", authed(http.HandlerFunc(s.me.get)))
|
|
mux.Handle("GET /me/public-ip", authed(http.HandlerFunc(s.me.publicIP)))
|
|
mux.Handle("POST /auth/ws-ticket", authed(http.HandlerFunc(s.wsTicket.issue)))
|
|
|
|
// Privacy / GDPR-style endpoints — host can export their data,
|
|
// delete their account, and record terms acceptance from the
|
|
// onboarding gate.
|
|
mux.Handle("GET /me/data-export", authed(http.HandlerFunc(s.privacy.dataExport)))
|
|
mux.Handle("DELETE /me", authed(http.HandlerFunc(s.privacy.deleteMe)))
|
|
mux.Handle("POST /me/accept-terms", authed(http.HandlerFunc(s.privacy.acceptTerms)))
|
|
|
|
// Host-facing event/guest/token writes are limited by user_id.
|
|
mux.Handle("POST /events",
|
|
authed(rl("events_create", 20, 24*time.Hour, userIDKey, http.HandlerFunc(s.events.create))))
|
|
mux.Handle("GET /events", authed(http.HandlerFunc(s.events.list)))
|
|
mux.Handle("GET /events/{id}", authed(http.HandlerFunc(s.events.get)))
|
|
mux.Handle("PATCH /events/{id}", authed(http.HandlerFunc(s.events.update)))
|
|
mux.Handle("DELETE /events/{id}", authed(http.HandlerFunc(s.events.delete)))
|
|
|
|
mux.Handle("POST /events/{id}/guests",
|
|
authed(rl("guests_create", 1000, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.create))))
|
|
mux.Handle("GET /events/{id}/guests", authed(http.HandlerFunc(s.guests.list)))
|
|
mux.Handle("PATCH /events/{id}/guests/{guest_id}",
|
|
authed(rl("guests_update", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.update))))
|
|
mux.Handle("DELETE /events/{id}/guests/{guest_id}",
|
|
authed(rl("guests_delete", 200, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.delete))))
|
|
|
|
// CSV import (Block E). Preview is cheap (no DB writes), so we keep
|
|
// its budget separate from commit's daily-row-add limit.
|
|
mux.Handle("POST /events/{id}/guests/import/preview",
|
|
authed(rl("guests_import_preview", 30, time.Hour, userIDKey, http.HandlerFunc(s.csv.preview))))
|
|
mux.Handle("POST /events/{id}/guests/import",
|
|
authed(rl("guests_import_commit", 20, 24*time.Hour, userIDKey, http.HandlerFunc(s.csv.commit))))
|
|
mux.Handle("GET /events/{id}/guests/import/template", authed(http.HandlerFunc(s.csv.template)))
|
|
|
|
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
|
|
|
|
// Block E — host analytics. Viewer+ on both endpoints; the Redis
|
|
// cache absorbs the dashboard's repeated visits.
|
|
mux.Handle("GET /events/{id}/analytics", authed(http.HandlerFunc(s.analytics.get)))
|
|
mux.Handle("GET /events/{id}/analytics/export.csv",
|
|
authed(http.HandlerFunc(s.analytics.exportCSV)))
|
|
|
|
// Block G — smarter fraud detection. Per-event thresholds, CIDR
|
|
// allowlists, and the verdict feedback inbox. Reads are viewer+;
|
|
// writes are editor+ (matches the rest of the event-edit surface).
|
|
mux.Handle("GET /events/{id}/security/thresholds",
|
|
authed(http.HandlerFunc(s.security.getThresholds)))
|
|
mux.Handle("PUT /events/{id}/security/thresholds",
|
|
authed(http.HandlerFunc(s.security.putThresholds)))
|
|
mux.Handle("GET /events/{id}/security/allowlist",
|
|
authed(http.HandlerFunc(s.security.listAllowlist)))
|
|
mux.Handle("POST /events/{id}/security/allowlist",
|
|
authed(http.HandlerFunc(s.security.addAllowlist)))
|
|
mux.Handle("DELETE /events/{id}/security/allowlist",
|
|
authed(http.HandlerFunc(s.security.removeAllowlist)))
|
|
mux.Handle("GET /events/{id}/security/feedback",
|
|
authed(http.HandlerFunc(s.security.listFeedback)))
|
|
mux.Handle("POST /events/{id}/access-logs/{log_id}/feedback",
|
|
authed(http.HandlerFunc(s.security.recordFeedback)))
|
|
|
|
// Block F — scheduled messages (reminders + broadcasts).
|
|
// All editor+ except the recipient-count preview which is viewer+.
|
|
mux.Handle("GET /events/{id}/messages",
|
|
authed(http.HandlerFunc(s.messages.list)))
|
|
mux.Handle("GET /events/{id}/messages/recipient-count",
|
|
authed(http.HandlerFunc(s.messages.recipientCount)))
|
|
mux.Handle("POST /events/{id}/messages",
|
|
authed(rl("messages_create", 100, 24*time.Hour, userIDKey, http.HandlerFunc(s.messages.create))))
|
|
mux.Handle("PATCH /events/{id}/messages/{message_id}",
|
|
authed(http.HandlerFunc(s.messages.update)))
|
|
mux.Handle("POST /events/{id}/messages/{message_id}/send-now",
|
|
authed(http.HandlerFunc(s.messages.sendNow)))
|
|
mux.Handle("DELETE /events/{id}/messages/{message_id}",
|
|
authed(http.HandlerFunc(s.messages.cancel)))
|
|
|
|
// Block H — day-of check-in.
|
|
mux.Handle("POST /events/{id}/check-in",
|
|
authed(rl("checkin_record", 1000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.record))))
|
|
mux.Handle("POST /events/{id}/walk-ins",
|
|
authed(rl("checkin_walk_in", 500, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.walkIn))))
|
|
mux.Handle("GET /events/{id}/check-ins",
|
|
authed(http.HandlerFunc(s.checkIns.list)))
|
|
|
|
// Block D — event branding. Reads are viewer+; PUT is editor+. The
|
|
// upload endpoint is gated by auth only (any signed-in user can mint
|
|
// an image URL; the URL is no use without an event they can edit
|
|
// branding on).
|
|
mux.Handle("GET /events/{id}/branding", authed(http.HandlerFunc(s.branding.get)))
|
|
mux.Handle("PUT /events/{id}/branding", authed(http.HandlerFunc(s.branding.put)))
|
|
mux.Handle("POST /uploads/image",
|
|
authed(rl("uploads_image", 30, time.Hour, userIDKey, http.HandlerFunc(s.uploads.post))))
|
|
// Public read — the guest RSVP page fetches the host's logo + cover
|
|
// without auth. Heavy cache; no rate limiter (one-time fetch per
|
|
// guest, behind the CDN in prod anyway).
|
|
mux.HandleFunc("GET /uploads/{filename}", s.uploads.serve)
|
|
|
|
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
|
|
// requireRole inside each handler enforces the right minimum role.
|
|
mux.Handle("GET /events/{id}/collaborators",
|
|
authed(http.HandlerFunc(s.collabs.list)))
|
|
mux.Handle("POST /events/{id}/collaborators",
|
|
authed(rl("collab_invite", 50, 24*time.Hour, userIDKey, http.HandlerFunc(s.collabs.invite))))
|
|
mux.Handle("PATCH /events/{id}/collaborators/{user_id}",
|
|
authed(http.HandlerFunc(s.collabs.updateRole)))
|
|
mux.Handle("DELETE /events/{id}/collaborators/{user_id}",
|
|
authed(http.HandlerFunc(s.collabs.remove)))
|
|
mux.Handle("DELETE /events/{id}/collaborators/pending",
|
|
authed(http.HandlerFunc(s.collabs.cancelInvite)))
|
|
|
|
// Invite acceptance — preview is unauthed (the invitee may not be
|
|
// logged in yet); accept requires auth (the caller's account must
|
|
// exist + match the invited email).
|
|
mux.HandleFunc("GET /invites/{token}", s.collabs.previewInvite)
|
|
mux.Handle("POST /invites/{token}/accept",
|
|
authed(http.HandlerFunc(s.collabs.acceptInvite)))
|
|
|
|
// Self-service invite inbox: bypasses the email-token round-trip so a
|
|
// user who lost the invite tab after email verification can still
|
|
// accept from their dashboard.
|
|
mux.Handle("GET /me/invites",
|
|
authed(http.HandlerFunc(s.collabs.myInvites)))
|
|
mux.Handle("POST /me/invites/{event_id}/accept",
|
|
authed(http.HandlerFunc(s.collabs.acceptForEvent)))
|
|
|
|
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
|
|
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
|
|
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
|
|
authed(rl("tokens_rotate", 200, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.rotate))))
|
|
mux.Handle("POST /events/{id}/guests/invitations/bulk",
|
|
authed(rl("tokens_bulk", 10, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.bulkIssue))))
|
|
|
|
// Guest-facing endpoints — rate-limited by the access token in the URL
|
|
// path so an attacker hammering a single invitation is slowed regardless
|
|
// of their source IP.
|
|
mux.Handle("GET /access/{token}",
|
|
rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access)))
|
|
// Block B: .ics download. Same rate-limit class as /access since the
|
|
// payload is similarly cheap and the abuse profile is identical (one
|
|
// token per attacker).
|
|
// Forwarded-link defence: when a guest opens their invitation from a
|
|
// device the original RSVP wasn't submitted from, /access hides the
|
|
// reply. This endpoint mints a short-lived edit nonce and emails it
|
|
// so the real guest can recover from a new phone / new laptop.
|
|
mux.Handle("POST /access/{token}/request-edit-link",
|
|
rl("rsvp_edit_request", 3, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.requestEditLink)))
|
|
|
|
mux.Handle("GET /access/{token}/calendar.ics",
|
|
rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar)))
|
|
mux.Handle("POST /rsvp/{token}",
|
|
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
|
|
// Block A: edits are bounded by MaxRSVPEdits server-side. The redis
|
|
// limiter is a coarser guard that also throttles attempts that hit the
|
|
// edit-cap 429 path, so a hostile actor can't burn through fraud-engine
|
|
// calls on the same token.
|
|
mux.Handle("PATCH /rsvp/{token}",
|
|
rl("rsvp_edit", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.edit)))
|
|
|
|
// Host view of the edit trail for a single guest.
|
|
mux.Handle("GET /events/{id}/guests/{guest_id}/rsvp/history",
|
|
authed(http.HandlerFunc(s.rsvps.history)))
|
|
|
|
// WebSocket endpoint authenticates via single-use ticket on the query
|
|
// string (see POST /auth/ws-ticket).
|
|
mux.HandleFunc("GET /ws/events/{id}", s.ws.handle)
|
|
|
|
// Unsubscribe (signed token, no auth required — links live in emails).
|
|
mux.HandleFunc("GET /unsubscribe/{token}", s.unsub.preview)
|
|
mux.HandleFunc("POST /unsubscribe/{token}", s.unsub.confirm)
|
|
|
|
// Provider webhooks. Signature verification is enforced in the handler
|
|
// once GG_TWILIO_AUTH_TOKEN / GG_SES_WEBHOOK_SECRET are set.
|
|
mux.HandleFunc("POST /webhooks/twilio/status", s.webhooks.twilio)
|
|
mux.HandleFunc("POST /webhooks/ses/notifications", s.webhooks.ses)
|
|
|
|
// Billing (Block F). /billing/status is safe for everyone — returns
|
|
// free tier defaults when Stripe is unconfigured or the user has no
|
|
// subscription, so the frontend's plan page always has something to
|
|
// render. The action endpoints (checkout, portal) return 503 in dev
|
|
// without Stripe credentials.
|
|
mux.Handle("GET /billing/status", authed(http.HandlerFunc(s.billing.status)))
|
|
mux.Handle("POST /billing/checkout-session", authed(http.HandlerFunc(s.billing.checkoutSession)))
|
|
mux.Handle("POST /billing/portal", authed(http.HandlerFunc(s.billing.portalSession)))
|
|
mux.HandleFunc("POST /webhooks/stripe", s.stripeWH.handle)
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
})
|
|
|
|
var h http.Handler = mux
|
|
h = corsMiddleware(h)
|
|
h = loggingMiddleware(s.logger)(h)
|
|
h = recoverMiddleware(s.logger)(h)
|
|
return h
|
|
}
|
|
|
|
// Permissive CORS for the dev frontend on a different origin. In production
|
|
// the frontend is served from the same domain so this is largely a no-op.
|
|
func corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Device-Fingerprint")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|