Files
guestguard/internal/api/server.go
T
Kwaku Danso e5b187c575 feat(tier2): event branding + UX polish — Block D
Backend
- Migration 0010 adds event_branding (one row per event; all fields
  nullable so a brand-new event renders with defaults)
- BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer
  preserves the existing value, "" clears the field to NULL
- internal/uploads package: ImageStore interface + LocalFSStore (dev),
  pure-stdlib decode + re-encode that strips EXIF and rejects anything
  that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames
- GET /events/{id}/branding (viewer+) returns the row plus the
  AllowedFonts list so the frontend picker stays in sync
- PUT /events/{id}/branding (editor+) validates hex colours, font
  allowlist, and refuses image URLs whose path doesn't start with
  /uploads/ (blocks arbitrary-origin <img> smuggling on guest pages)
- POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file}
  serves with year-long cache (immutable random names)
- GET /access/{token} now embeds the host's branding so the RSVP page
  can render in their colours/font with their logo + cover
- docker-compose mounts a named volume for uploads
- Custom-domain sub-block deferred to Tier 3 per the plan

Frontend
- BrandingCard.vue: colour pickers, font dropdown, logo + cover upload
  with progressive disclosure, live preview pane that re-renders on
  every keystroke
- RSVP page applies branding via CSS vars at the section root, so
  primary colour theme + font cascade through every child card. Cover
  image renders as a banner above the form; logo lands in the header
- Submit button background switches to var(--brand-primary) when set
- Mounted on the event detail page below the guests block

Plus the small UX fixes from the e2e walkthrough:
- Nav: dropped the top-level "Events" link; the logo doubles as the
  home affordance (→ /dashboard when signed in, → / otherwise). Account
  + Billing + Sign out live under a profile dropdown (avatar with
  initials, opens on click, closes on outside-click / Esc / route nav)
- Renamed "Back to dashboard" → "Back to events" across event detail,
  billing, account, and new-event pages

Tests
- TestBrandingGetReturnsDefaults / TestBrandingPutPersists /
  TestBrandingPutRejectsBadInputs / TestUploadAndServeImage /
  TestUploadRejectsNonImage — all pass
- Domain tests for IsValidHexColor + IsAllowedFont
- Full integration suite green (176s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:09 +01:00

483 lines
18 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
}
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)
// 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,
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,
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,
},
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("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 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).
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, 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)
})
}