Files
guestguard/internal/api/server.go
T
Kwaku Danso 3629ab8c79 fix(tier2-g): rebrand Security → Gate with event-planner language
The original Block G UI was correct but felt like a firewall config panel.
The audience for GuestGuard is wedding couples, party hosts, and event
planners — not network admins. Three concrete problems:

1. "Fraud detection" / "Risk score" frames every guest as a suspect.
   "Gate" is the metaphor every host already uses ("gate the guest
   list", "doorman"). It's the same idea, friendlier.

2. Three threshold sliders (0–100) are abstract. Hosts can't reason
   about "block at 85" without context. Replaced by four presets —
   Relaxed / Balanced / Strict / Very strict — each with a sentence
   that explains the kind of event it fits. The sliders survive under
   an Advanced disclosure for the rare power user.

3. CIDR notation is gibberish to a layperson. "Trusted networks" is
   what they're actually doing. A "Use my current network" button
   detects the host's apparent IP, widens IPv4 to /24 (typical home
   block), and pre-fills the form. Manual entry stays for cases
   where the host knows what they're doing.

Plus two leaks fixed on the guest-facing RSVP page: removed the
"Risk score 72 · high" line from both the confirmation and the
blocked-attempt cards. Guests should never see internal scoring
detail — the blocked message now reads "Something about this
attempt looked off" instead of "suspicious access attempt".

Backend
- New GET /me/public-ip (authed) — echoes the caller's apparent IP
  so the frontend's auto-detect button doesn't need a third-party
  ipify call

Frontend
- New components/GateCard.vue with preset cards + advanced disclosure
  + trusted-networks UX with auto-detect
- Removed components/SecurityCard.vue
- Event tab nav: Security → Gate. Hash-link alias preserves any
  /events/<id>#security bookmarks
- RSVP page: leaked risk-score lines removed; blocked-attempt
  message reworded for non-technical guests

Internal storage / API names stay (FraudThresholds, fraud_v2,
/security/* endpoints) — they're never user-visible and renaming
them would be a breaking change for no end-user benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:15:11 +01:00

514 lines
19 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
}
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)
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,
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,
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,
},
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 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, 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)
})
}