package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log" "net/http" "os" "sync" "crussell/db" ) type SquareWebhookEvent struct { Type string `json:"type"` EventID string `json:"event_id"` CreatedAt string `json:"created_at"` Data json.RawMessage `json:"data"` LocationID string `json:"location_id"` } // squareWebhookDedup is a bounded, mutex-guarded set of recently handled // event IDs. It is a FAST-PATH cache only: the persistent source of truth is // the square_webhook_events table (see HandleSquareWebhook). It lets a replayed // delivery be dropped without a DB round-trip, but a restart clears it — the DB // row is what guarantees at-most-once processing across restarts. type squareWebhookDedup struct { mu sync.Mutex seen map[string]struct{} order []string max int } func newSquareWebhookDedup(max int) *squareWebhookDedup { return &squareWebhookDedup{ seen: make(map[string]struct{}), order: make([]string, 0, max), max: max, } } // has reports whether id is in the set WITHOUT recording it. Used as the // fast-path short-circuit before the DB dedup insert. func (d *squareWebhookDedup) has(id string) bool { d.mu.Lock() defer d.mu.Unlock() _, ok := d.seen[id] return ok } // register records id, reporting whether it was already present (set untouched // on a replay, preserving insertion order). Mutex-guarded — the handler may be // hit concurrently. Called only after the DB insert has confirmed the event's // fate, so a failed DB write never leaves a stale entry that would drop a retry. func (d *squareWebhookDedup) register(id string) bool { d.mu.Lock() defer d.mu.Unlock() if _, ok := d.seen[id]; ok { return true } d.seen[id] = struct{}{} d.order = append(d.order, id) if len(d.order) > d.max { oldest := d.order[0] d.order = d.order[1:] delete(d.seen, oldest) } return false } // 500 IDs far exceeds the latency payoff of the fast-path cache; the DB row // (square_webhook_events) is the unbounded, restart-safe source of truth. var squareWebhookEventsSeen = newSquareWebhookDedup(500) // HandleSquareWebhook verifies and dispatches Square webhook events. // // Fail-closed chain: 503 when the signing key is unset, 403 on a missing/bad // signature, 400 on malformed JSON or an empty event_id (which cannot be // deduplicated — Square always sends one, so this is defensive). A correctly // signed, well-formed event is deduplicated by event_id before dispatch and // acknowledged 200. func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 512*1024) body, err := io.ReadAll(r.Body) if err != nil { log.Printf("Failed to read webhook body: %v", err) http.Error(w, "request body too large or unreadable", http.StatusRequestEntityTooLarge) return } defer r.Body.Close() // Verification logic per Square spec (HMAC-SHA256, base64, notificationURL + body). // Production setup: set SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_NOTIFICATION_URL // in env vars (see Square Developer Console → Webhooks → Subscription). // Reference: https://developer.squareup.com/docs/webhooks/step3validate // Fail closed: a missing signing key means the webhook cannot be verified, // so reject rather than process unauthenticated events (S-4). Square // always sends the signature header, so an unset key in production is a // misconfiguration that must not silently accept forged events. signingKey := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY") notificationURL := os.Getenv("SQUARE_WEBHOOK_NOTIFICATION_URL") if notificationURL == "" { notificationURL = "http://localhost:8080/webhooks/square" } if signingKey == "" { log.Printf("SQUARE_WEBHOOK_SIGNATURE_KEY is not set — rejecting webhook (fail-closed)") http.Error(w, "webhook signature verification unavailable", http.StatusServiceUnavailable) return } signature := r.Header.Get("x-square-hmacsha256-signature") if signature == "" { log.Printf("Missing Square webhook signature header") http.Error(w, "Invalid signature", http.StatusForbidden) return } if !verifySquareSignature(body, signature, signingKey, notificationURL) { log.Printf("Invalid Square webhook signature") http.Error(w, "Invalid signature", http.StatusForbidden) return } var event SquareWebhookEvent if err := json.Unmarshal(body, &event); err != nil { log.Printf("Failed to parse webhook event: %v", err) http.Error(w, "Invalid event", http.StatusBadRequest) return } // An empty event_id cannot be deduplicated. Square always sends event_id, // so this is defensive — but once handlers mutate state, a duplicate // empty-ID event would double-apply. Reject with 400 (fail-safe, no // dispatch, no dedup row): Square's retry policy retries on 5xx/timeouts // but treats 4xx as non-retryable, so the malformed event is dropped // without side effects. if event.EventID == "" { log.Printf("[SQUARE-WEBHOOK] Rejecting event with empty event_id (400)") http.Error(w, "Invalid event", http.StatusBadRequest) return } // Dedup BEFORE dispatch: a correctly signed replay of a handled event must // not re-enter the handlers (which will mutate state once wired). The // in-memory fast-path drops recent replays without a DB round-trip; the // square_webhook_events INSERT ... ON CONFLICT DO NOTHING is the source of // truth — 0 rows affected means the event was already handled (persisted // from before a restart, or a concurrent duplicate) and dispatch is // skipped. Returns 200 to acknowledge delivery without processing. // // ORDERING NOTE: the dedup row is committed before dispatch. If the process // crashes between the insert and dispatch, the event is dropped (Square's // retry is 200-skipped). This is acceptable while dispatch is log-only; // when handlers mutate state, switch to dispatch-then-record or make // dispatch idempotent. if event.EventID != "" { if squareWebhookEventsSeen.has(event.EventID) { log.Printf("[SQUARE-WEBHOOK] Duplicate event_id %s; skipping (already processed)", event.EventID) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) return } if db.Conn == nil { log.Printf("[SQUARE-WEBHOOK] DB unavailable — rejecting event_id %s (fail-closed)", event.EventID) http.Error(w, "webhook processing unavailable", http.StatusServiceUnavailable) return } tag, err := db.Conn.Exec(r.Context(), "INSERT INTO square_webhook_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING", event.EventID) if err != nil { // Fail closed: without a successful dedup write we cannot prove this // event hasn't been handled before, so reject and let Square retry // later. event_id is not PII, so logging it is safe. log.Printf("[SQUARE-WEBHOOK] Failed to record event_id %s (dedup write failed): %v", event.EventID, err) http.Error(w, "webhook processing unavailable", http.StatusServiceUnavailable) return } // Record in the fast-path cache only after the DB write succeeds, so a // failed write never leaves a stale entry that would drop a retry. squareWebhookEventsSeen.register(event.EventID) if tag.RowsAffected() == 0 { log.Printf("[SQUARE-WEBHOOK] Duplicate event_id %s; skipping (already processed)", event.EventID) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) return } } log.Printf("[SQUARE-WEBHOOK] Received event: %s", event.Type) switch event.Type { case "payment.updated": handlePaymentUpdated(event.Data) case "refund.updated": handleRefundUpdated(event.Data) case "dispute.created": log.Printf("[SQUARE-WEBHOOK] Dispute created: %s", event.EventID) default: log.Printf("[SQUARE-WEBHOOK] Unknown event type: %s", event.Type) } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } func verifySquareSignature(body []byte, signature, signingKey, notificationURL string) bool { mac := hmac.New(sha256.New, []byte(signingKey)) mac.Write([]byte(notificationURL)) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) } // handlePaymentUpdated logs only the Square object id — never the raw payload, // which contains PII (buyer email, card brand/last4, cardholder name, billing // address, amounts). The envelope's event_id is logged at the dispatch site. // On unmarshal failure log just the byte length (no content). func handlePaymentUpdated(data json.RawMessage) { var obj struct{ ID string `json:"id"` } if err := json.Unmarshal(data, &obj); err != nil { log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data)) return } log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", obj.ID) } // handleRefundUpdated logs only the Square object id — never the raw payload, // which contains PII. See handlePaymentUpdated. func handleRefundUpdated(data json.RawMessage) { var obj struct{ ID string `json:"id"` } if err := json.Unmarshal(data, &obj); err != nil { log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data)) return } log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", obj.ID) }