Security (P0): - IsJTIRevoked fails closed on DB error (previously accepted revoked tokens) - Remove dead consume parameter from SCA gate (prevented token replay) - Rate limiter map TTL-based eviction (prevented memory exhaustion) - 2FA attempt map already had LRU eviction (verified) Money Safety (P1): - Gift card transfer refuses expired destination cards - Gift card balance deduction has WHERE balance >= amount guard - Webhook clawback acquires till-sale advisory lock - Sweep/retry lock keys aligned Privacy/Cookies (P2): - Self-host Google Fonts (Playfair Display woff2) - Replace CARTO map tiles with OpenStreetMap raster tiles - Replace Wikimedia/icon-icons external images with local SVGs - Remove external image URLs from CSP Legal (P3): - Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies) - Terms: add Tips section (optionality, non-refundable, same processing as bookings) Code Quality (P4): - twofa.Check accepts db.Querier for testability - depositPromotionMinPct uses literal 0.20 (not misleading alias) - HolidayHours.svelte uses proper type (not as any[]) - Remove stale TODO comments from main.go Testing (P5): - 94 new float64 money validity tests across 3 test files - Cover VAT, splits, refunds, gift cards, rounding, precision boundaries - All 27 backend test packages pass
952 lines
43 KiB
Go
952 lines
43 KiB
Go
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. In an enforced env a token-less saved-card charge is refused 402
|
|
// verification_required (SCA-only — see the posture note below); in a
|
|
// dev/mock env the gate lifts and the mock simulates SCA. Warn loudly when
|
|
// a non-dev env (empty/unknown — a likely misconfiguration) leaves the gate
|
|
// disabled.
|
|
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 local-dev [2FA] stdout log; production builds NEVER log it —
|
|
// stdout-log delivery is a dev/test-only feature, not a production
|
|
// channel. With no email/SMS transport wired yet (P6), a production
|
|
// build has NO delivery channel at all, so 2FA code issuance FAILS
|
|
// CLOSED and no user can complete 2FA setup or disable until the
|
|
// email/SMS transport lands. Warn loudly so the operator is never
|
|
// misled into thinking codes are reaching users when issuance is
|
|
// actually failing closed.
|
|
log.Printf("WARNING: 2FA enforcement is ON, but this is not a dev/test build: stdout-log delivery is a LOCAL DEV ONLY feature and email/SMS is not wired yet (P6), so there is NO code-delivery channel and 2FA code issuance FAILS CLOSED — no user can complete 2FA setup or disable until email/SMS delivery is implemented. Card charges are unaffected (SCA-only). Run a dev/test build for local log-delivery testing.")
|
|
}
|
|
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-only posture (permanent — no switch): saved-card charges are
|
|
// authorised exclusively by Square PSD2 SCA. PSR 2017 reg 100 makes SCA
|
|
// mandatory and non-waivable for customer-initiated stored-credential
|
|
// charges, and the homegrown 2FA cannot legally act as an SCA fallback: a
|
|
// token-less saved-card charge is refused 402 verification_required ("pay
|
|
// online later") — a fallback-authorised charge would leave the MERCHANT
|
|
// liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6)
|
|
// compensation, and consent does not cure it. The dev mock simulates SCA
|
|
// (SimulateSavedCardVerificationRequired + cnon:sca-... tokenize-results),
|
|
// so development has full parity. There is no TWO_FACTOR_FALLBACK switch to
|
|
// warn about — it was removed; a token-less charge can never be authorised
|
|
// by 2FA in any environment.
|
|
// 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()
|
|
checkSquareCredentials()
|
|
}
|
|
|
|
// 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. That runtime fallback is a money-safety convenience for the dev/mock
|
|
// stack (no real PII), but in a REAL deployment a missing/invalid key would
|
|
// leave buyer email + ccof card tokens unencrypted at rest — so this startup
|
|
// check FAILS CLOSED there, mirroring the JWT_SECRET_KEY gate (main.go init):
|
|
// in a non-mock environment the key must be present and decode to exactly 32
|
|
// bytes (AES-256), or the process refuses to start. The warn+plaintext-fallback
|
|
// posture survives ONLY in explicit dev/mock environments (no real data).
|
|
func checkSnapshotEncKey() {
|
|
if payments.IsExplicitDevOrMockEnv() {
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
|
|
switch {
|
|
case raw == "":
|
|
log.Fatalf("FATAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) would be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT"))
|
|
default:
|
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
|
switch {
|
|
case err != nil:
|
|
log.Fatalf("FATAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows would 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.Fatalf("FATAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows would 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.
|
|
// A key with no URL is therefore FATAL (not a warning): the operator has
|
|
// explicitly configured webhooks, so every event silently failing signature
|
|
// verification would strand payment/refund reconciliation mid-run. The fail
|
|
// stays availability-only (the handler is fail-closed, so no event — genuine
|
|
// or forged — can mutate state), but it must be unmissable at boot.
|
|
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.Fatalf("FATAL: 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"))
|
|
}
|
|
}
|
|
|
|
// checkSquareCredentials fail-closes the REAL Square API credentials at
|
|
// startup in non-mock deployments. The env interpretation (dev/mock gate)
|
|
// stays here via payments.IsExplicitDevOrMockEnv — the single source — and
|
|
// the value validation itself lives in internal/square
|
|
// (square.ValidateCredentials) next to the client that consumes the
|
|
// credentials, so the check cannot drift from the code that reads them.
|
|
func checkSquareCredentials() {
|
|
if payments.IsExplicitDevOrMockEnv() {
|
|
return
|
|
}
|
|
square.ValidateCredentials()
|
|
}
|
|
|
|
// checkS3ProfilePicsBucket warns at startup when S3_PROFILE_PICS_BUCKET is
|
|
// unset in a non-dev/mock deployment. The bucket is only needed for profile-pic
|
|
// deletion operations, not for the app to function, so this is a WARNING not a
|
|
// Fatal. If R2_ENDPOINT is set (S3 client configured) but the bucket is not,
|
|
// the warning is elevated to CRITICAL because the deletion path will be silently
|
|
// skipped at runtime — every profile-pic erasure will log a one-time CRITICAL
|
|
// and skip the S3 deletion, leaving the object in place.
|
|
func checkS3ProfilePicsBucket() {
|
|
if payments.IsExplicitDevOrMockEnv() {
|
|
return
|
|
}
|
|
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
r2Endpoint := os.Getenv("R2_ENDPOINT")
|
|
switch {
|
|
case bucket != "":
|
|
// The deletion path can run, but a non-dev build's S3 client is the
|
|
// "not implemented" stub — every Delete fails and the retry job will
|
|
// eventually hit its attempt cap and raise a critical notification.
|
|
if s3.ClientIsStub {
|
|
log.Printf("CRITICAL: this build's S3 client is the stub that cannot perform real operations — profile-picture deletions will always fail (retry-s3-deletions will exhaust its attempts and raise a critical admin notification). Compile with the real AWS SDK (dev build) or implement the production S3 client before relying on R2 object-store erasure.")
|
|
}
|
|
case r2Endpoint != "":
|
|
log.Printf("CRITICAL: S3_PROFILE_PICS_BUCKET is not set but R2_ENDPOINT=%q is configured — the S3 client is active but profile-picture deletion will be silently skipped at runtime (every erasure logs a one-time CRITICAL and skips the S3 delete). Set S3_PROFILE_PICS_BUCKET to the bucket holding profile pictures.", r2Endpoint)
|
|
default:
|
|
log.Printf("WARNING: S3_PROFILE_PICS_BUCKET is not set — profile-picture deletion from object storage will be skipped. This is safe if no object store is configured; set it when R2_ENDPOINT is configured.")
|
|
}
|
|
}
|
|
|
|
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")
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
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()
|
|
checkS3ProfilePicsBucket()
|
|
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)
|
|
// Change-password and delete-account get a dedicated per-user limiter
|
|
// (10/min) on top of the group's generic 120/min per-IP limiter:
|
|
// these endpoints re-verify the current password, and without a
|
|
// per-user budget a stolen session token lets an attacker brute-force
|
|
// that password with unlimited guesses (the account lockout is the
|
|
// last line of defence; the per-user limiter is the first).
|
|
accountLimiter := mw.RateLimitByUser(10, time.Minute)
|
|
r.With(accountLimiter).Put("/user/change-password", user.ChangePasswordHandler)
|
|
r.With(accountLimiter).Delete("/user/account", user.DeleteAccountHandler)
|
|
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)
|
|
// Admin-side loyalty redemption: the admin "Take Payment"
|
|
// PaymentModal applies the customer's pending 10% loyalty
|
|
// redemption on the booking's behalf (the customer-facing
|
|
// /bookings/{id}/apply-redemption route below stays
|
|
// RequireNonGuest). ApplyLoyaltyRedemption resolves the acting
|
|
// role from context and lets an admin redeem on any booking.
|
|
r.Post("/{id}/apply-redemption", payments.ApplyLoyaltyRedemption)
|
|
})
|
|
|
|
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")
|
|
}
|