Files
gsinghpal 2a363c6b40 changes
2026-04-02 03:30:02 -04:00

186 lines
6.6 KiB
Python

# -*- 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)