186 lines
6.6 KiB
Python
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)
|