package main import ( "context" "crussell/auth" "crussell/internal/dav" "crussell/internal/jobs" "crussell/internal/logutil" "crussell/internal/s3" "crussell/internal/square" "encoding/base64" "encoding/json" "fmt" "log" "net/http" "os" "os/signal" "strings" "sync/atomic" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "crussell/db" "crussell/mw" "crussell/handlers/admin" authHandlers "crussell/handlers/auth" "crussell/handlers/bookings" "crussell/handlers/notifications" "crussell/handlers/payments" "crussell/handlers/portfolio" "crussell/handlers/scheduling" "crussell/handlers/services" "crussell/handlers/today" "crussell/handlers/user" "crussell/handlers/webhooks" ) func init() { // The testing framework passes -test.* to the binary; skip JWT init // because test packages handle it via testutils/jwt. This is more // precise than checking GO_TESTING env var, which can leak from the // test runner into the environment during seeding. for _, arg := range os.Args { if strings.HasPrefix(arg, "-test.") { return } } jwtSecret := os.Getenv("JWT_SECRET_KEY") if jwtSecret == "" { log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.") } // Fail-closed: a weak, publicly-known, or placeholder JWT_SECRET_KEY must // not start the server. Every deployment copying .env.example unchanged // would otherwise share the SAME signing key, letting anyone forge an // admin JWT (gift-card minting, refunds, saved-card access). if isWeakJWTSecret(jwtSecret) { log.Fatal("FATAL: JWT_SECRET_KEY is too weak: it must be at least 32 characters and not a known placeholder value (the current value is publicly documented). Generate a strong random key and set it, e.g. `openssl rand -hex 32`.") } auth.InitJWT(jwtSecret) } // minJWTSecretBytes is the minimum length enforced for JWT_SECRET_KEY. HS256 // needs at least 32 bytes (256 bits) to be meaningful; shorter keys are // trivially brute-forceable and every deployment sharing one is forgeable. const minJWTSecretBytes = 32 // weakJWTSecretValues lists known placeholder/example values for // JWT_SECRET_KEY that are public (documented in .env.example, READMEs, or // attack tooling) and must never be accepted as a signing key. var weakJWTSecretValues = []string{ "a-very-secret-key-that-should-be-in-env", "change-me", "changeme", "changethis", "CHANGE_ME", "secret", "password", "your-secret-key", "your-secret", "jwt-secret", "jwt-secret-key", "default-secret", "my-secret", "test-secret", "test-secret-key", "test-secret-key-for-testing-only", "super-secret", } // isWeakJWTSecret reports whether a JWT_SECRET_KEY is a known placeholder, // shorter than the minimum safe length, or lacks sufficient entropy. func isWeakJWTSecret(secret string) bool { trimmed := strings.TrimSpace(strings.ToLower(secret)) if len(trimmed) < minJWTSecretBytes { return true } for _, weak := range weakJWTSecretValues { if trimmed == weak { return true } } // Entropy gate (B15): at least 8 distinct bytes. All-same-char and // tiny-alphabet secrets ("aaaa...", "0000...", "abcdabcdabcd...") pass the // length and blacklist checks but have trivial key space — HS256 keys need // meaningful entropy, not just length. A strong random secret (even pure // hex, which can use at most 16 distinct chars) clears the 8-byte bar. seen := make(map[byte]struct{}, 8) for i := 0; i < len(trimmed); i++ { seen[trimmed[i]] = struct{}{} if len(seen) >= 8 { return false } } return len(seen) < 8 } func limitBody(limit int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, limit) } next.ServeHTTP(w, r) }) } } const ( defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB uploadBodyLimit int64 = 20 * 1024 * 1024 // 20MB portfolioBodyLimit int64 = 30 * 1024 * 1024 // 30MB (7 variants) ) // nColor / bColor — Chi-style ANSI colors for request logging. type nColor string type bColor string var ( reset = nColor(logutil.Reset) nYellow = nColor(logutil.Yellow) nCyan = nColor(logutil.Cyan) bGreen = bColor(logutil.BoldGreen) bYellow = bColor(logutil.BoldYellow) bRed = bColor(logutil.BoldRed) bBlue = bColor(logutil.BoldBlue) bMagenta = bColor(logutil.BoldMagenta) debugLvl = nColor(logutil.DebugLvl) warnLvl = nColor(logutil.WarnLvl) errorLvl = nColor(logutil.ErrorLvl) ) var apiLog = log.New(os.Stdout, "", log.LstdFlags) func initDB() { if err := db.Connect(); err != nil { log.Fatal("Failed to connect to DB:", err) } fmt.Println("Connected to DB successfully") } func initDav() { if dav.Service == nil { log.Fatal("Failed to initialize DAV service") } fmt.Println("DAV Service connected successfully") } func initS3() { if err := s3.Connect(); err != nil { log.Printf("WARNING: Failed to connect to S3: %v", err) } else { fmt.Println("S3 client initialized") } } func initSquare() { payments.SquareClient = square.NewClient() env := os.Getenv("SQUARE_ENVIRONMENT") if env == "sandbox" || env == "production" { fmt.Printf("Square client initialized (%s, real API)\n", env) } else { fmt.Println("Square client initialized (dev mock)") } // 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is // OFF only when REQUIRE_2FA explicitly disables it (false/0/off/no, // case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock // stack. Warn loudly when a non-dev env (empty/unknown — a likely // misconfiguration) leaves the gate disabled, so saved-card charges can // never silently ship without this merchant-level authorization gate (an // additional fraud control; NOT PSD2 SCA — Square buyer verification is the // SCA mechanism, wired for new-card charges). enforced := payments.NewPaymentService().TwoFactorEnforced() if enforced { // Delivery is build-dependent (handlers/user/twofa_dev.go / // twofa_prod.go): dev/test builds ALWAYS write the plaintext code to // the [2FA] log line; production builds write it ONLY when the operator // explicitly opts in with TWO_FACTOR_ALLOW_LOG_DELIVERY=true and refuse // issuance otherwise. Warn accurately per case so the operator is never // misled into thinking codes are reaching users when issuance is // actually failing closed. if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" { log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.") } else { log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete setup or disable. Every enforced saved-card online payment for a user without 2FA will 403 with no way to enable it. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).") } } if !enforced && !payments.IsExplicitDevOrMockEnv() { log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env) } // SCA-primary / 2FA-backup posture (TWO_FACTOR_FALLBACK, default "true"): // the homegrown 2FA gate is the BACKUP authorization for saved-card charges // that carry no Square verification_token. TWO_FACTOR_FALLBACK=false makes // the deployment SCA-only: a token-less saved-card charge is then rejected // 402 verification_required (the frontend shows the 3DS challenge; if the // bank cannot do SCA the payment cannot proceed). Warn when the operator // opted out so the SCA-only posture is a deliberate, visible choice. if !payments.NewPaymentService().TwoFactorFallbackEnabled() && !payments.IsExplicitDevOrMockEnv() { log.Printf("WARNING: TWO_FACTOR_FALLBACK=false with SQUARE_ENVIRONMENT=%q — the homegrown 2FA is DISABLED as a saved-card charge fallback. Only charges carrying a Square verification_token (SCA performed) can proceed; a token-less charge is rejected 402 verification_required.", env) } // The mirror-image confusion: enforcement is ON but the Square client fell // back to the in-memory mock (internal/square.NewDevClient only picks the // real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or // unknown. The operator may believe they are in dev — warn so enforced-2FA // 403s on online saved-card payments do not arrive as a surprise. if enforced && env != "sandbox" && env != "production" { log.Printf("WARNING: 2FA enforcement is ON but SQUARE_ENVIRONMENT=%q is empty/unknown — the Square client is the dev mock while the 2FA gate stays enforced (fail-closed). Online saved-card payments will 403 until users enable 2FA; set SQUARE_ENVIRONMENT to a dev value (mock/dev/development/test) to lift the gate, or to sandbox/production for the real API.", env) } checkSnapshotEncKey() checkProxyRateLimitConfig() checkWebhookSignatureKey() } // checkSnapshotEncKey validates SNAPSHOT_ENC_KEY at startup in non-mock // deployments. charge_helpers.snapshotEncKey() (handlers/payments) parses the // key on every call and silently falls back to storing square_request_snapshot // rows PLAINTEXT (buyer PII: email + ccof card tokens) with a one-time CRITICAL // log. This startup check makes the misconfiguration unmissable at boot: the // key must be present and decode to exactly 32 bytes (AES-256). Money-safety // first — it warns CRITICAL but does NOT fail the process (a failing startup // would strand pending replayable snapshots), matching the runtime fallback. func checkSnapshotEncKey() { if payments.IsExplicitDevOrMockEnv() { return } raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY")) switch { case raw == "": log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT")) return default: decoded, err := base64.StdEncoding.DecodeString(raw) switch { case err != nil: log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", err, os.Getenv("SQUARE_ENVIRONMENT")) case len(decoded) != 32: log.Printf("CRITICAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", len(decoded), os.Getenv("SQUARE_ENVIRONMENT")) } } } // checkProxyRateLimitConfig warns when per-IP rate limiting collapses to a // single GLOBAL budget: TRUST_PROXY_HEADERS is unset/false (the shipped // default — .env.example ships false, compose.yml never sets it) while // SQUARE_ENVIRONMENT selects a real deployment (sandbox/production/empty). // Behind a trusted proxy (the nginx in compose.yml), every request's // RemoteAddr is the proxy's IP, so clientIP() returns the SAME key for all // users and any one client can exhaust the shared per-IP budget — permanently // 429ing the whole surface for everyone. The user+IP 2FA limiter is immune // (each authenticated account gets its own bucket), but every other per-IP // limiter still collapses. Set TRUST_PROXY_HEADERS=true when a trusted proxy // (nginx and/or the Cloudflare edge) sits in front and overwrites X-Real-IP / // CF-Connecting-IP with the real client IP. func checkProxyRateLimitConfig() { if payments.IsExplicitDevOrMockEnv() { return } if mw.TrustProxyHeaders() { return } log.Printf("WARNING: TRUST_PROXY_HEADERS is unset/false with SQUARE_ENVIRONMENT=%q (not a dev/mock value) — behind a trusted proxy (e.g. the nginx in compose.yml) every per-IP rate-limit key uses the proxy's RemoteAddr, collapsing all rate limiters to ONE global budget that any single client can exhaust for everyone. Set TRUST_PROXY_HEADERS=true when a trusted proxy sits in front and overwrites X-Real-IP/CF-Connecting-IP; keep it false only when the backend is origin-exposed.", os.Getenv("SQUARE_ENVIRONMENT")) } // checkWebhookSignatureKey validates SQUARE_WEBHOOK_SIGNATURE_KEY at startup in // non-mock deployments. Square webhooks are HMAC-signed and the handler rejects // unsigned/mis-signed events fail-closed (503 without the key, 403 on a bad // signature), so an empty key silently disables the only integration path that // reconciles payments and refunds from Square. Mirroring the fail-fast // JWT_SECRET_KEY check (main.go init): when an operator HAS configured a // notification URL (they clearly intend to receive webhooks) but left the // signing key empty, startup FAILS — discovering mid-run that every event is // rejected would strand payment reconciliation. When the URL is also unset // (webhooks not in use — the documented optional setup) a loud warning is // logged instead, so the fail-fast never breaks webhook-less deployments. // // Round 2 Loop A finding 6 (the reverse misconfiguration): when the KEY is set // but the URL is unset, the handler falls back to the public default // http://localhost:8080/webhooks/square (webhooks/square.go) and HMAC // verification runs against that string, so every GENUINE Square event fails // signature verification (403) and payment/refund reconciliation is silently // broken. The key signals intent to use webhooks; the missing URL breaks them. // This is WARN not FATAL: the handler is fail-closed, so no event — genuine or // forged — can mutate state, making it availability-only (like // checkProxyRateLimitConfig), not a security hole. func checkWebhookSignatureKey() { if payments.IsExplicitDevOrMockEnv() { return } keySet := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY") != "" urlSet := os.Getenv("SQUARE_WEBHOOK_NOTIFICATION_URL") != "" switch { case keySet && urlSet: return case keySet && !urlSet: log.Printf("WARNING: SQUARE_WEBHOOK_SIGNATURE_KEY IS set but SQUARE_WEBHOOK_NOTIFICATION_URL is unset with SQUARE_ENVIRONMENT=%q (non-mock) — the webhook handler falls back to the default http://localhost:8080/webhooks/square, so every GENUINE Square event fails signature verification (403, fail-closed) and payment/refund reconciliation is silently broken. Set SQUARE_WEBHOOK_NOTIFICATION_URL to exactly the notification URL configured in the Square Dashboard webhook subscription.", os.Getenv("SQUARE_ENVIRONMENT")) case !keySet && urlSet: log.Fatalf("FATAL: SQUARE_WEBHOOK_SIGNATURE_KEY environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) while SQUARE_WEBHOOK_NOTIFICATION_URL IS set — Square webhook events (payment/refund reconciliation) would be rejected fail-closed at runtime. Generate the signing key in the Square Dashboard webhook subscription and set it in .env.", os.Getenv("SQUARE_ENVIRONMENT")) default: log.Printf("CRITICAL: SQUARE_WEBHOOK_SIGNATURE_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) and SQUARE_WEBHOOK_NOTIFICATION_URL is unset — Square webhooks are not configured; any webhook event Square sends will be rejected (503, fail-closed). Set both in .env if you rely on webhook payment/refund reconciliation.", os.Getenv("SQUARE_ENVIRONMENT")) } } func healthCheckHandler(w http.ResponseWriter, r *http.Request) { status := "ok" services := map[string]string{ "backend": "ok", "database": "ok", "s3_storage": "ok", "square_payments": "ok", "frontend": "unknown", } if db.Conn != nil { if err := db.Conn.Ping(r.Context()); err != nil { services["database"] = "error" status = "degraded" } } else { services["database"] = "error" status = "degraded" } var s3Message string if s3.Client == nil { services["s3_storage"] = "not_configured" } else if s3.FallbackToInMemory { // The dev build fell back to the in-memory client: uploads are stored // in RAM with placeholder cdn.example.com URLs and are lost on restart. services["s3_storage"] = "degraded" status = "degraded" s3Message = "S3 in-memory fallback active: RUSTFS unreachable — portfolio/photo uploads use placeholder URLs and data is lost on restart" } // Display-only label: uses the shared env set PLUS the empty default (dev // builds fall back to the mock client when SQUARE_ENVIRONMENT is unset, but // IsExplicitDevOrMockEnv is intentionally fail-closed for empty). if payments.IsExplicitDevOrMockEnv() || os.Getenv("SQUARE_ENVIRONMENT") == "" { services["square_payments"] = "mock" } if status == "degraded" { w.WriteHeader(http.StatusServiceUnavailable) } else { w.WriteHeader(http.StatusOK) } resp := map[string]any{ "status": status, "services": services, } if s3Message != "" { resp["message"] = s3Message } if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // corsAllowedOrigins returns the frontend origins permitted to call the API, // read from the comma-separated FRONTEND_ORIGIN env var. Entries are trimmed // and blanks dropped; an unset/empty var falls back to the local dev origin. func corsAllowedOrigins() []string { var allowed []string for _, o := range strings.Split(os.Getenv("FRONTEND_ORIGIN"), ",") { if o = strings.TrimSpace(o); o != "" { allowed = append(allowed, o) } } if len(allowed) == 0 { allowed = []string{"http://localhost:5173"} } return allowed } // originAllowed reports whether origin is exactly in the allowlist. func originAllowed(origin string, allowed []string) bool { for _, o := range allowed { if origin == o { return true } } return false } // corsMiddleware sets security headers plus a CORS allowlist so credentialed // cross-origin requests (Authorization: Bearer) work only from configured // frontend origins. func corsMiddleware(next http.Handler) http.Handler { allowedOrigins := corsAllowedOrigins() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") // TODO: Enable HSTS in production w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") // TODO: Enable Referrer-Policy in production w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") origin := r.Header.Get("Origin") if origin != "" && originAllowed(origin, allowedOrigins) { w.Header().Set("Access-Control-Allow-Origin", origin) 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", "Authorization, Content-Type, Idempotency-Key") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func main() { initDB() initDav() initS3() initSquare() sched := jobs.New() jobs.RegisterAll(sched) sched.Start() r := chi.NewRouter() // --- Global Middleware --- // Custom RequestID using shared random prefix (matches jobs scheduler) var reqCounter atomic.Uint64 r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { myid := reqCounter.Add(1) requestID := fmt.Sprintf("%s/%s-%06d", jobs.Hostname(), jobs.RandomPrefix(), myid) ctx := context.WithValue(r.Context(), middleware.RequestIDKey, requestID) next.ServeHTTP(w, r.WithContext(ctx)) }) }) // Trust the X-Real-IP proxy header ONLY when TRUST_PROXY_HEADERS=true, // i.e. a trusted proxy (nginx/Cloudflare) sits in front and overwrites it // with the real client IP. When the backend is origin-exposed, X-Real-IP // is fully client-controlled and must be ignored, or a client could rotate // it to bypass per-IP rate limiting. Without this middleware, chi's // GetClientIP returns "" and the mw rate limiters fall back to the real // RemoteAddr (the TCP peer). Same flag gates the mw.clientIP CF-Connecting- // IP trust (mw/ratelimit_shared.go). if mw.TrustProxyHeaders() { r.Use(middleware.ClientIPFromHeader("X-Real-IP")) } r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) t1 := time.Now() defer func() { status := ww.Status() bytes := ww.BytesWritten() reqID := middleware.GetReqID(r.Context()) var level nColor var statusColor bColor switch { case status >= 500: level, statusColor = errorLvl, bRed case status >= 400: level, statusColor = warnLvl, bYellow default: level, statusColor = debugLvl, bGreen } clientIP := middleware.GetClientIP(r.Context()) if clientIP == "" { clientIP = r.RemoteAddr } apiLog.Printf("%s %s%s%s \"%s%s %s%s %s%s\" from %s - %s %s%03d%s %s%dB%s in %s", level, nYellow, reqID, reset, bMagenta, r.Method, nCyan, r.URL.String(), nCyan, r.Proto, reset, clientIP, statusColor, status, reset, bBlue, bytes, reset, logutil.ColoredDuration(time.Since(t1)), ) }() next.ServeHTTP(ww, r) }) }) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(15 * time.Second)) // CORS + security headers: credentialed cross-origin requests // (Authorization: Bearer) are only answered for origins in the configured // FRONTEND_ORIGIN allowlist — never reflected blindly, so a leaked JWT // cannot be used from a rogue site. r.Use(corsMiddleware) // All API routes grouped under /api for clarity r.Route("/api", func(r chi.Router) { r.Use(mw.JsonContentType) // Public read-only (but check auth context if present for eligibility) r.Group(func(r chi.Router) { r.Use(mw.RateLimit(120, time.Minute)) r.Use(mw.OptionalAuth) r.Get("/services", services.ServicesHandler) r.Get("/services/popular", services.PopularServicesHandler) }) // Per-user eligibility (B4): requires authentication, and the handler // itself enforces owner-or-admin. The user-agnostic /api/services // stays public; the per-user variant exposes DOB-derived age + patch // test status, so an arbitrary user_id must never be queryable // unauthenticated (the frontend calls it only with the current user's // ID, or via the admin booking flows). r.With(mw.RateLimit(120, time.Minute), mw.RequireAuth).Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler) // Registration: 10/min to prevent spam + progressive per-IP backoff r.With(mw.ProgressiveRateLimit, mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/register", authHandlers.RegisterHandler) // Login: Has its own internal rate limiting + progressive per-IP backoff r.With(mw.ProgressiveRateLimit, mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler) // Logout: requires valid token r.With(mw.RequireAuth).Post("/logout", authHandlers.LogoutHandler) // Email verification r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler) r.With(mw.RateLimit(20, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/check", authHandlers.VerifyCodeHandler) // Health check r.Get("/health", healthCheckHandler) // Public contact info r.Get("/contact", user.GetContactInfoHandler) // Public contact-availability (no auth required) r.With(mw.RateLimit(120, time.Minute)).Get("/contact-availability", scheduling.GetContactAvailability) // Public business info (limited, safe for non-admin users) r.Get("/business-info", admin.GetPublicBusinessInfo) // Portfolio r.Route("/portfolio", func(r chi.Router) { r.Get("/images", portfolio.ListImages) r.Get("/tags", portfolio.ListTags) r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters) r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage) r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) r.Use(mw.RequireAdmin) r.Use(mw.RateLimit(60, time.Minute)) r.With(limitBody(portfolioBodyLimit)).Post("/images", portfolio.UploadImage) r.Delete("/images/{id}", portfolio.DeleteImage) }) }) // Scheduling r.Route("/scheduling", func(r chi.Router) { r.Use(mw.RateLimit(120, time.Minute)) r.Use(mw.OptionalAuth) r.Get("/default-hours", scheduling.GetDefaultHours) r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) r.Get("/working-hours", scheduling.GetWorkingHours) r.Get("/available-hours", scheduling.GetAvailableHours) r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) r.Use(mw.RequireAdmin) r.Use(mw.RateLimit(60, time.Minute)) r.Get("/preview-available-hours", scheduling.GetPreviewAvailableHours) r.Put("/default-hours", scheduling.UpdateDefaultHours) r.Post("/default-hours/conflicting", scheduling.GetDefaultHoursConflictingBookings) r.Post("/default-hours/schedule", scheduling.ScheduleDefaultHoursChange) r.Get("/default-hours/scheduled", scheduling.GetScheduledDefaultHoursChange) r.Delete("/default-hours/scheduled", scheduling.CancelScheduledDefaultHoursChange) r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup) r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications) }) }) // Public booking endpoints (optional auth for slot reservation and guest bookings) r.Group(func(r chi.Router) { r.Use(mw.RateLimit(30, time.Minute), mw.OptionalAuth) r.Use(limitBody(defaultBodyLimit)) r.Post("/bookings/reserve", bookings.ReserveSlotHandler) r.Post("/bookings", bookings.CreateBookingHandler) }) // Guest user creation (public, no auth required) r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/users/guest", user.CreateGuestUserHandler) // Public email check (used by BookingFlow for proactive registered-email detection) r.With(mw.RateLimit(60, time.Minute)).Get("/check-email", user.CheckEmailHandler) // Refresh-token exchange (B5): the client presents the opaque refresh // token in the Authorization header (Bearer). This MUST be outside the // RequireAuth group — the refresh token is NOT a JWT, so RequireAuth // would reject it. The handler itself validates + rotates the refresh // token and mints a fresh access-token/refresh-token pair. r.With(mw.RateLimit(10, time.Minute)).Post("/refresh-token", authHandlers.RefreshTokenHandler) // Authenticated users r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) r.Use(mw.RateLimit(120, time.Minute)) r.Use(limitBody(defaultBodyLimit)) r.Get("/user/profile", user.GetProfileHandler) r.Put("/user/profile", user.UpdateProfileHandler) r.Put("/user/change-password", user.ChangePasswordHandler) r.Get("/user/notification-preferences", user.GetNotificationPreferencesHandler) r.Put("/user/notification-preferences", user.UpdateNotificationPreferencesHandler) // 2FA settings — merchant-level authorization gate on saved-card // payments; NOT PSD2 SCA (Square buyer verification is the SCA // mechanism, wired for new-card charges); kept as an additional // fraud control until Square verification is wired for saved-card // charges. // RequireNonGuest: any logged-in user who could save cards must be // able to reach these, not just verified accounts. r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler) // The code-issuing/verifying endpoints get a dedicated per-user // limiter (10/min) on top of the group's generic 120/min per-IP // limiter: // the 6-digit codes live in a 1M space, so a single user must not be // able to hammer setup/verify/disable/code-mint faster than the // per-user 5-attempt lockout can trip. The key is the authenticated // userID ALONE (RateLimitByUser) — NOT user+IP (B8): with the IP in // the key, a client that can rotate its source IP (or that sits behind // a proxy echoing a client-supplied CF-Connecting-IP when // TRUST_PROXY_HEADERS=true) mints a fresh bucket per IP for the // same account, collapsing the per-account budget. One shared // limiter for all five so the whole 2FA surface counts against a // single per-user budget. twoFALimiter := mw.RateLimitByUser(10, time.Minute) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable/code", user.SendDisableCodeHandler) // Fresh-code request for an already-ENABLED user making a saved-card // charge (the B6/B10 gate). Setup refuses enabled users (409) and // setup clears the pending code on success, so this is the only mint // path for an enabled user. Same middleware chain + shared limiter as // the rest of the 2FA surface. r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/code", user.SendVerificationCodeHandler) r.Delete("/user/account", user.DeleteAccountHandler) r.Get("/user/gdpr-export", user.GetGDPRExportHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler) r.Get("/bookings", bookings.GetAllUserBookingsHandler) r.Get("/bookings/{id}", bookings.GetBookingHandler) r.Get("/bookings/{id}/calendar", bookings.GetBookingCalendarHandler) r.Put("/bookings/{id}", bookings.EditBookingHandler) r.Delete("/bookings/{id}", bookings.DeleteBookingHandler) r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler) r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler) r.Get("/bookings/{id}/edit-request", bookings.GetMyEditRequestHandler) r.Get("/bookings/edit-requests", bookings.GetMyEditRequestsHandler) r.Delete("/bookings/reserve", bookings.CancelReservationHandler) // User payment routes // Product rule (security): guests (account_role='guest') must not // pay online — RequireNonGuest 403s any token with a guest role // claim before the money handlers run. r.With(mw.RequireNonGuest).Post("/bookings/{id}/payment", payments.CreateBookingPayment) r.With(mw.RequireNonGuest).Post("/bookings/{id}/apply-redemption", payments.ApplyLoyaltyRedemption) r.With(mw.RequireNonGuest).Post("/bookings/{id}/payment-lock", payments.AcquirePaymentLock) r.With(mw.RequireNonGuest).Delete("/bookings/{id}/payment-lock", payments.ReleasePaymentLock) // Product rule (security): cards may only be saved by verified // accounts — RequireAuth (group mw above) runs first and injects // userID/role into ctx, then RequireVerified 403s the rest. r.With(mw.RequireVerified).Get("/user/payment-methods", payments.GetUserPaymentMethods) r.With(mw.RequireVerified).Post("/user/payment-methods", payments.CreatePaymentMethod) r.With(mw.RequireVerified).Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod) r.With(mw.RequireNonGuest).Post("/bookings/{id}/tip", payments.CreateTipPayment) r.Get("/bookings/{id}/payment-summary", payments.GetBookingPaymentSummary) r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)). Get("/bookings/{id}/discount-preview", payments.GetDiscountPreviewHandler) // User gift card routes // Dedicated redeem limiter (B16): a gift-card code lives in the // 12-hex space, so redemption is brute-forceable. The redeem route // gets a per-user 10/min budget (RateLimitByUser — one bucket per // account regardless of IP rotation) on top of the group's generic // 120/min limiter. r.With(mw.RequireNonGuest, mw.RateLimitByUser(10, time.Minute)).Post("/user/giftcards/redeem", payments.RedeemGiftCard) r.Get("/user/giftcards/balance", payments.GetGiftCardBalance) r.With(mw.RequireNonGuest).Post("/user/giftcards/buy", payments.BuyGiftCard) // 14-day cooling-off right to cancel online gift-card purchases // (Consumer Contracts (Information, Cancellation and Additional // Charges) Regulations 2013): list the caller's cancellable cards // and execute the cancel+refund. RequireNonGuest mirrors /buy — // guests cannot buy (or cancel) gift cards. r.Get("/user/giftcards", payments.GetMyGiftCards) r.With(mw.RequireNonGuest).Post("/user/giftcards/cancel", payments.CancelGiftCard) }) r.With(mw.RequireAuth, mw.RequireVerified, limitBody(uploadBodyLimit)).Post("/user/profile-picture", user.UploadProfilePictureHandler) // Admin-only. The group now carries a per-IP limiter (300/min) to bound // the per-request DB amplification of the auth path (VerifyToken runs a // JTI-revocation query + a family-alive query per request — MEDIUM-1): // an unthrottled admin surface lets a single compromised admin session // hammer the DB. 300/min is far above any legitimate admin UI usage and // keys per-IP (RateLimitByUser would be a no-op — there is exactly one // admin account, so per-user == global). The trusted-session model is // unchanged; this only bounds amplification. r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) r.Use(mw.RequireAdmin) r.Use(mw.RateLimit(300, time.Minute)) r.Use(limitBody(defaultBodyLimit)) r.Route("/admin/services", func(r chi.Router) { r.Post("/", services.CreateServiceHandler) r.Delete("/{id}", services.DeleteServiceHandler) r.Get("/", services.AllServicesHandler) r.Put("/{id}/toggle", services.ToggleService) }) r.Route("/admin/patch-tests", func(r chi.Router) { r.Get("/", admin.GetPatchTests) r.Post("/", admin.CreatePatchTest) r.Put("/{id}", admin.UpdatePatchTest) r.Delete("/{id}", admin.DeletePatchTest) }) r.Route("/admin/custom-services", func(r chi.Router) { r.Get("/", admin.GetCustomServices) r.Post("/", admin.CreateCustomService) r.Get("/{id}", admin.GetCustomService) r.Put("/{id}", admin.UpdateCustomService) r.Post("/{id}/promote", admin.PromoteCustomService) r.Delete("/{id}", admin.DeleteCustomService) }) r.Route("/admin/bookings", func(r chi.Router) { r.Get("/", bookings.GetAllAdminBookingsHandler) r.Post("/", bookings.AdminCreateBookingForUserHandler) r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler) r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/{id}", bookings.GetAdminBookingHandler) r.Put("/{id}", bookings.UpdateBookingServicesHandler) r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler) r.Get("/overlapping", bookings.GetOverlappingBookingsByTimeHandler) r.Get("/by-date-range", bookings.GetBookingsByDateRangeHandler) r.Post("/conflicting-for-exception", scheduling.GetConflictingBookingsForExceptionHandler) r.Get("/by-created-range", bookings.GetBookingsByCreatedRangeHandler) r.Put("/{id}/reschedule", bookings.AdminRescheduleBookingHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler) r.Post("/reserve", bookings.AdminReserveSlotHandler) r.Delete("/reserve", bookings.AdminCancelReservationHandler) // Edit request endpoints r.Get("/edit-requests", bookings.AdminListAllEditRequestsHandler) r.Get("/{id}/edit-request", bookings.AdminGetBookingEditRequestHandler) r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler) r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler) }) r.Route("/admin/users", func(r chi.Router) { r.Get("/", user.ListAdminUsersHandler) r.Get("/{id}", user.GetAdminUserHandler) r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler) r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods) r.Post("/{id}/2fa/remove", user.AdminRemoveUser2FAHandler) // Admin-scoped 2FA mint: the operator requests a code FOR the // customer whose saved card is being charged at the till/admin // payment modal. Mints keyed to the CUSTOMER so the code is // delivered to the customer and can satisfy the card-owner gate. r.With(mw.RateLimitByUser(10, time.Minute)).Post("/{id}/2fa/code", user.AdminSendVerificationCodeHandler) }) r.Route("/admin/today", func(r chi.Router) { r.Get("/current-next", today.GetCurrentAndNextHandler) r.Get("/appointments", today.GetTodayAppointmentsHandler) r.Get("/pending-approvals", today.GetPendingApprovalsHandler) }) r.Route("/admin/notifications", func(r chi.Router) { r.Get("/", notifications.GetNotifications) r.Get("/unread-count", notifications.GetUnreadCount) r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) }) r.Route("/admin/time-blockers", func(r chi.Router) { r.Get("/", scheduling.ListTimeBlockers) r.Post("/", scheduling.CreateTimeBlocker) r.Delete("/{id}", scheduling.DeleteTimeBlocker) }) r.Route("/admin/discount-campaigns", func(r chi.Router) { r.Get("/", admin.GetDiscountCampaigns) r.Post("/", admin.CreateDiscountCampaign) r.Put("/{id}", admin.UpdateDiscountCampaign) r.Delete("/{id}", admin.DeleteDiscountCampaign) r.Get("/{id}/stats", admin.GetCampaignStats) }) // Admin payment routes r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment) r.Post("/admin/bookings/{id}/refund", payments.AdminRefundBooking) r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus) r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment) // Admin gift card routes r.Get("/admin/gift-cards", payments.GetGiftCards) r.Post("/admin/gift-cards", payments.CreateGiftCard) r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard) r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard) r.Post("/admin/gift-cards/cancel", payments.AdminCancelGiftCard) r.Get("/admin/gift-cards/expired-balances", payments.GetExpiredBalances) r.Post("/admin/gift-cards/expired-balances/claim", payments.ClaimExpiredBalance) // Admin till sale routes (POS transactions not linked to bookings) r.Post("/admin/till/sale", payments.CreateTillSale) r.Get("/admin/till/sale/checkout/{checkout_id}/status", payments.GetTillCheckoutStatus) r.Route("/admin/settings", func(r chi.Router) { r.Get("/", admin.GetBusinessSettings) r.Put("/", admin.UpdateBusinessSettings) }) }) }) // Webhooks (no auth - Square sends to base path) r.Post("/webhooks/square", webhooks.HandleSquareWebhook) srv := &http.Server{ Addr: ":8080", Handler: r, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) go func() { <-quit log.Println("Shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("Server forced to shutdown: %v", err) } <-sched.Shutdown() log.Println("Background jobs stopped") }() fmt.Println("Server is listening on :8080") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) } log.Println("Server exited") }