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" ) 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 } 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 } 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) 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, 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, }, 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 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))) 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) }) }