"""Webhook endpoint for automation triggers. External services call POST /api/v1/webhooks/{token} with a JSON body containing {"action": "activate"} or {"action": "deactivate"} to control automations that have a webhook condition. No API-key auth is required — the secret token itself authenticates the caller. """ import time from collections import defaultdict from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from wled_controller.api.dependencies import get_automation_engine, get_automation_store from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.storage.automation import WebhookCondition from wled_controller.storage.automation_store import AutomationStore from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() # --------------------------------------------------------------------------- # Simple in-memory rate limiter: 30 requests per 60-second window per IP # --------------------------------------------------------------------------- _RATE_LIMIT = 30 _RATE_WINDOW = 60.0 # seconds _rate_hits: dict[str, list[float]] = defaultdict(list) def _check_rate_limit(client_ip: str) -> None: """Raise 429 if *client_ip* exceeded the webhook rate limit.""" now = time.time() window_start = now - _RATE_WINDOW # Prune timestamps outside the window timestamps = _rate_hits[client_ip] _rate_hits[client_ip] = [t for t in timestamps if t > window_start] if len(_rate_hits[client_ip]) >= _RATE_LIMIT: raise HTTPException( status_code=429, detail="Rate limit exceeded. Max 30 webhook requests per minute.", ) _rate_hits[client_ip].append(now) class WebhookPayload(BaseModel): action: str = Field(description="'activate' or 'deactivate'") @router.post( "/api/v1/webhooks/{token}", tags=["Webhooks"], ) async def handle_webhook( token: str, body: WebhookPayload, request: Request, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """Receive a webhook call and set the corresponding condition state.""" _check_rate_limit(request.client.host if request.client else "unknown") if body.action not in ("activate", "deactivate"): raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'") # Find the automation that owns this token for automation in store.get_all_automations(): for condition in automation.conditions: if isinstance(condition, WebhookCondition) and condition.token == token: active = body.action == "activate" await engine.set_webhook_state(token, active) logger.info( "Webhook %s: automation '%s' (%s) → %s", token[:8], automation.name, automation.id, body.action, ) return { "ok": True, "automation_id": automation.id, "automation_name": automation.name, "action": body.action, } raise HTTPException(status_code=404, detail="Webhook token not found")