# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import json import logging import time from collections import defaultdict from odoo import http from odoo.http import request, Response from ..lib.woo_api_client import WooApiClient _logger = logging.getLogger(__name__) def _normalize_url(url): """Strip trailing slashes and lowercase for comparison.""" return url.rstrip('/').lower() if url else '' class WooWebhookController(http.Controller): """Receive inbound WooCommerce webhook deliveries.""" # Simple in-memory rate limiter: {ip: [(timestamp, ...),]} _rate_tracker = defaultdict(list) _RATE_LIMIT = 60 # max requests per minute (1/sec sustained) _RATE_WINDOW = 60 # seconds @classmethod def _check_rate_limit(cls, ip): """Return True if the IP is within rate limits, False if exceeded.""" now = time.time() cutoff = now - cls._RATE_WINDOW # Clean old entries cls._rate_tracker[ip] = [ ts for ts in cls._rate_tracker[ip] if ts > cutoff ] if len(cls._rate_tracker[ip]) >= cls._RATE_LIMIT: return False cls._rate_tracker[ip].append(now) return True def _find_instance(self, source_url): """Find a woo.instance matching the webhook source URL.""" instances = request.env['woo.instance'].sudo().search([ ('state', '!=', 'draft'), ]) norm_source = _normalize_url(source_url) for inst in instances: if _normalize_url(inst.url) == norm_source: return inst return None def _handle_ping(self, topic): """Return 200 for WooCommerce webhook test deliveries.""" _logger.info("WooCommerce webhook ping received. Topic: %s", topic) return Response('OK', status=200) def _verify_and_dispatch(self, dispatch_method): """ Common handler for all webhook endpoints. - Rate limits by IP - Detects ping - Finds instance by source URL - Verifies HMAC signature - Delegates to dispatch_method(instance, data) Returns a Response. """ # Rate limiting remote_ip = request.httprequest.remote_addr or 'unknown' if not self._check_rate_limit(remote_ip): _logger.warning("Rate limit exceeded for IP %s", remote_ip) return Response('Too Many Requests', status=429) headers = request.httprequest.headers topic = headers.get('X-WC-Webhook-Topic', '') source_url = headers.get('X-WC-Webhook-Source', '') signature = headers.get('X-WC-Webhook-Signature', '') payload = request.httprequest.get_data() # WooCommerce sends a test ping on webhook creation — body may be empty or minimal if not payload or payload.strip() in (b'', b'{}', b'[]'): return self._handle_ping(topic) # Find matching instance instance = self._find_instance(source_url) if not instance: _logger.warning( "WooCommerce webhook: no matching instance for source URL '%s'", source_url ) # Return 200 to prevent WooCommerce from retrying indefinitely return Response('No matching instance', status=200) # Verify HMAC signature if instance.webhook_secret: if not WooApiClient.verify_webhook_signature(payload, signature, instance.webhook_secret): _logger.warning( "WooCommerce webhook: invalid signature for instance '%s'", instance.name ) return Response('Unauthorized', status=401) else: _logger.warning( "WooCommerce webhook: instance '%s' has no webhook_secret — skipping signature check", instance.name, ) # Parse JSON body try: data = json.loads(payload) except (json.JSONDecodeError, ValueError) as exc: _logger.error("WooCommerce webhook: invalid JSON body — %s", exc) return Response('Bad Request', status=400) # Dispatch to model method try: dispatch_method(instance, data, topic) except Exception: _logger.exception( "WooCommerce webhook: error dispatching topic '%s' for instance '%s'", topic, instance.name, ) return Response('Internal Error', status=500) return Response('OK', status=200) # ------------------------------------------------------------------------- # Webhook Endpoints # ------------------------------------------------------------------------- @http.route( '/woo/webhook/order', type='http', auth='none', csrf=False, methods=['POST'], save_session=False, ) def webhook_order(self, **kw): """Receive order.created / order.updated from WooCommerce.""" def dispatch(instance, data, topic): _logger.info( "WooCommerce order webhook received. Instance: %s, Topic: %s, Order ID: %s", instance.name, topic, data.get('id'), ) if hasattr(instance, '_sync_order_from_wc'): instance._sync_order_from_wc(data) return self._verify_and_dispatch(dispatch) @http.route( '/woo/webhook/product', type='http', auth='none', csrf=False, methods=['POST'], save_session=False, ) def webhook_product(self, **kw): """Receive product.updated from WooCommerce.""" def dispatch(instance, data, topic): _logger.info( "WooCommerce product webhook received. Instance: %s, Topic: %s, Product ID: %s", instance.name, topic, data.get('id'), ) if hasattr(instance, '_sync_product_from_wc'): instance._sync_product_from_wc(data) return self._verify_and_dispatch(dispatch) @http.route( '/woo/webhook/customer', type='http', auth='none', csrf=False, methods=['POST'], save_session=False, ) def webhook_customer(self, **kw): """Receive customer.created / customer.updated from WooCommerce.""" def dispatch(instance, data, topic): _logger.info( "WooCommerce customer webhook received. Instance: %s, Topic: %s, Customer ID: %s", instance.name, topic, data.get('id'), ) if hasattr(instance, '_sync_customer_from_wc'): instance._sync_customer_from_wc(data) return self._verify_and_dispatch(dispatch)