Spaces:
Paused
Paused
| """ | |
| 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__) | |
| # Rate limit: 30 requests per minute per IP | |
| WEBHOOK_RATE_LIMIT = 30 | |
| WEBHOOK_RATE_WINDOW = 60 # seconds | |
| 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 | |
| """ | |
| # Rate limiting by IP | |
| 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) | |
| # Find script by token | |
| 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, | |
| ) | |
| # Check if script can run (enabled and not archived) | |
| 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, | |
| ) | |
| # Extract webhook data | |
| webhook_data = _extract_webhook_data(request) | |
| # Create a new Run record | |
| run = Run.objects.create( | |
| script=script, | |
| status=Run.Status.PENDING, | |
| triggered_by=None, # Webhook-triggered, no user | |
| trigger_type=Run.TriggerType.API, | |
| code_snapshot=script.code, | |
| ) | |
| # Store webhook data in the run for the executor | |
| # We'll pass this through a custom field or via task args | |
| run._webhook_data = webhook_data | |
| # Queue for async execution | |
| 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 "", | |
| } | |
| # Extract body for POST requests | |
| if request.method == "POST": | |
| try: | |
| body = request.body.decode("utf-8") | |
| data["body"] = body | |
| # Try to parse as JSON for convenience | |
| if request.content_type == "application/json": | |
| try: | |
| data["body_json"] = json.loads(body) | |
| except json.JSONDecodeError: | |
| pass | |
| except Exception: | |
| data["body"] = "" | |
| return data | |