pyrunner / core /views /webhooks.py
Perspicacious's picture
Deploy PyRunner on Hugging Face
2529305
Raw
History Blame Contribute Delete
4.42 kB
"""
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
@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
"""
# 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