| """ |
| Webhook views for triggering scripts via HTTP. |
| """ |
|
|
| import json |
| import logging |
|
|
| from django.core.cache import cache |
| from django.http import JsonResponse, HttpRequest |
| from django.views.decorators.csrf import csrf_exempt |
| from django.views.decorators.http import require_http_methods |
|
|
| from core.models import Script, Run |
| from core.tasks import queue_script_run |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| WEBHOOK_RATE_LIMIT = 30 |
| WEBHOOK_RATE_WINDOW = 60 |
|
|
|
|
| @csrf_exempt |
| @require_http_methods(["GET", "POST"]) |
| def webhook_trigger_view(request: HttpRequest, token: str) -> JsonResponse: |
| """ |
| Public endpoint to trigger script execution via webhook. |
| |
| Accepts GET and POST requests. For POST, the request body and query |
| parameters are passed to the script as environment variables. |
| |
| Args: |
| request: The HTTP request |
| token: The webhook token (64-char URL-safe string) |
| |
| Returns: |
| JsonResponse with: |
| - 200: Script queued successfully |
| - 403: Script is disabled |
| - 404: Invalid token |
| - 429: Rate limit exceeded |
| - 500: Failed to queue |
| """ |
| |
| client_ip = request.META.get("REMOTE_ADDR", "unknown") |
| rate_key = f"webhook_rate_{client_ip}" |
| requests_count = cache.get(rate_key, 0) |
|
|
| if requests_count >= WEBHOOK_RATE_LIMIT: |
| logger.warning(f"Webhook rate limit exceeded for IP: {client_ip}") |
| return JsonResponse( |
| {"error": "Rate limit exceeded. Try again later."}, |
| status=429, |
| ) |
|
|
| cache.set(rate_key, requests_count + 1, WEBHOOK_RATE_WINDOW) |
|
|
| |
| try: |
| script = Script.objects.select_related("environment").get(webhook_token=token) |
| except Script.DoesNotExist: |
| logger.warning(f"Webhook trigger with invalid token: {token[:8]}...") |
| return JsonResponse( |
| {"error": "Invalid webhook token"}, |
| status=404, |
| ) |
|
|
| |
| if not script.can_run: |
| reason = "archived" if script.is_archived else "disabled" |
| logger.info(f"Webhook trigger rejected - script {reason}: {script.name}") |
| return JsonResponse( |
| {"error": f"Script is {reason}"}, |
| status=403, |
| ) |
|
|
| |
| webhook_data = _extract_webhook_data(request) |
|
|
| |
| run = Run.objects.create( |
| script=script, |
| status=Run.Status.PENDING, |
| triggered_by=None, |
| trigger_type=Run.TriggerType.API, |
| code_snapshot=script.code, |
| ) |
|
|
| |
| |
| run._webhook_data = webhook_data |
|
|
| |
| try: |
| queue_script_run(run, webhook_data=webhook_data) |
| logger.info(f"Webhook triggered run {run.id} for script {script.name}") |
|
|
| return JsonResponse({ |
| "status": "queued", |
| "run_id": str(run.id), |
| "script": script.name, |
| }) |
|
|
| except Exception as e: |
| run.status = Run.Status.FAILED |
| run.stderr = f"Failed to queue task: {str(e)}" |
| run.save() |
| logger.error(f"Webhook failed to queue run {run.id}: {e}") |
|
|
| return JsonResponse( |
| {"error": "Failed to queue script execution"}, |
| status=500, |
| ) |
|
|
|
|
| def _extract_webhook_data(request: HttpRequest) -> dict: |
| """ |
| Extract webhook data from the request. |
| |
| Returns a dict with: |
| - method: GET or POST |
| - body: Request body as string (for POST) |
| - query: Query parameters as dict |
| - content_type: Request content type |
| """ |
| data = { |
| "method": request.method, |
| "query": dict(request.GET), |
| "content_type": request.content_type or "", |
| } |
|
|
| |
| if request.method == "POST": |
| try: |
| body = request.body.decode("utf-8") |
| data["body"] = body |
|
|
| |
| if request.content_type == "application/json": |
| try: |
| data["body_json"] = json.loads(body) |
| except json.JSONDecodeError: |
| pass |
| except Exception: |
| data["body"] = "" |
|
|
| return data |
|
|