package codes import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/apierr" ) // WebhookHandler handles POST /webhook/store/codes requests from the // card store (发卡店). It is mounted outside /v1 and requires no JWT. // // Security model: // - HMAC-SHA256 signature in X-Pangolin-Signature: sha256= // - Unix timestamp in X-Pangolin-Timestamp (±5 min window) // - Unique nonce in X-Pangolin-Nonce to prevent replay attacks // - Nonce is stored in Redis with a TTL > 2× the timestamp window type WebhookHandler struct { store *Store rdb *redis.Client secret []byte timestampTolerance time.Duration nonceTTL time.Duration } // NewWebhookHandler creates a WebhookHandler. // secret is the raw HMAC key (not hex-encoded). func NewWebhookHandler( store *Store, rdb *redis.Client, secret string, timestampTolerance time.Duration, nonceTTL time.Duration, ) *WebhookHandler { return &WebhookHandler{ store: store, rdb: rdb, secret: []byte(secret), timestampTolerance: timestampTolerance, nonceTTL: nonceTTL, } } // WebhookPayload is the JSON body sent by the card store. type WebhookPayload struct { // Code is the plaintext activation code generated by the card store. Code string `json:"code"` // Plan is the plan tier (free|pro|team). Plan string `json:"plan"` // DurationDays is the number of days this code grants. DurationDays int `json:"duration_days"` // Note is an optional human-readable remark. Note string `json:"note,omitempty"` } // redisKeyNonce returns the Redis key for a webhook nonce. func redisKeyNonce(nonce string) string { return "webhook:nonce:" + nonce } // ServeHTTP implements http.Handler for POST /webhook/store/codes. func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Read body (limit to 64 KB to prevent DoS). body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) if err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } // --- Signature verification --- if apiErr := h.verifySignature(r, body); apiErr != nil { apierr.WriteJSON(w, http.StatusUnauthorized, apiErr) return } // --- Timestamp window --- if apiErr := h.verifyTimestamp(r); apiErr != nil { apierr.WriteJSON(w, http.StatusUnauthorized, apiErr) return } // --- Nonce deduplication --- nonce := r.Header.Get("X-Pangolin-Nonce") if nonce == "" { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } isDupe, apiErr := h.checkAndStoreNonce(r.Context(), nonce) if apiErr != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if isDupe { // Idempotent: return 200 to acknowledge without re-inserting. w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "duplicate_ignored"}) return } // --- Parse payload --- var payload WebhookPayload if err := json.Unmarshal(body, &payload); err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } if payload.Code == "" || payload.Plan == "" || payload.DurationDays <= 0 { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } // --- Canonicalize and hash the code --- canonical, cerr := Canonicalize(payload.Code) if cerr != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrInvalidCode) return } hash := Hash(canonical) // --- Idempotent code insertion --- ctx := r.Context() planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))) if err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } // Create batch (one per webhook call; the store may aggregate externally). batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID) if err == ErrDuplicate { // Same code_hash already exists – idempotent, do not error. w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"}) return } if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"status": "created"}) } // verifySignature checks the X-Pangolin-Signature header. // Expected format: "sha256=" // HMAC is computed over the raw request body. func (h *WebhookHandler) verifySignature(r *http.Request, body []byte) *apierr.Error { sig := r.Header.Get("X-Pangolin-Signature") if sig == "" { return apierr.ErrWebhookSignature } if !strings.HasPrefix(sig, "sha256=") { return apierr.ErrWebhookSignature } gotHex := strings.TrimPrefix(sig, "sha256=") gotBytes, err := hex.DecodeString(gotHex) if err != nil { return apierr.ErrWebhookSignature } mac := hmac.New(sha256.New, h.secret) mac.Write(body) expected := mac.Sum(nil) // Constant-time comparison to prevent timing attacks. if !hmac.Equal(gotBytes, expected) { return apierr.ErrWebhookSignature } return nil } // verifyTimestamp checks the X-Pangolin-Timestamp header. // The timestamp must be within ± h.timestampTolerance of the current time. func (h *WebhookHandler) verifyTimestamp(r *http.Request) *apierr.Error { tsStr := r.Header.Get("X-Pangolin-Timestamp") if tsStr == "" { return apierr.ErrWebhookTimestamp } ts, err := strconv.ParseInt(tsStr, 10, 64) if err != nil { return apierr.ErrWebhookTimestamp } t := time.Unix(ts, 0) now := time.Now().UTC() diff := now.Sub(t) if diff < 0 { diff = -diff } if diff > h.timestampTolerance { return apierr.ErrWebhookTimestamp } return nil } // checkAndStoreNonce checks whether nonce has been seen before. // If not seen, it stores it in Redis with the nonce TTL and returns false. // If already seen, it returns true (duplicate). // Uses SET NX to make the check-and-store atomic. // Returns an error if rdb is nil (misconfiguration). func (h *WebhookHandler) checkAndStoreNonce(ctx context.Context, nonce string) (bool, error) { if h.rdb == nil { return false, fmt.Errorf("webhook: Redis client is not configured") } key := redisKeyNonce(nonce) // SET key "1" NX EX set, err := h.rdb.SetNX(ctx, key, "1", h.nonceTTL).Result() if err != nil { return false, fmt.Errorf("webhook checkNonce: %w", err) } // SetNX returns true if the key was set (not a duplicate). return !set, nil }