6a83d51d88
- Sanitise uploaded filenames with os.path.basename() to prevent path traversal - Change TestWebhookDispatchIntegration marker from unit to integration Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
312 lines
10 KiB
Python
312 lines
10 KiB
Python
"""API endpoints for Zapier / Make.com automation integration.
|
||
|
||
Provides a REST hooks subscription interface for outgoing triggers and
|
||
incoming action endpoints that external automation platforms can call.
|
||
|
||
Outgoing triggers:
|
||
External platforms subscribe to DocuElevate events via
|
||
``POST /api/automation/hooks/subscribe``. When a subscribed event
|
||
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
|
||
registered ``target_url``.
|
||
|
||
Incoming actions:
|
||
``POST /api/automation/actions/upload`` allows automation platforms to
|
||
push documents into DocuElevate for processing.
|
||
|
||
Authentication:
|
||
All endpoints require a valid API token via ``Authorization: Bearer``
|
||
header.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
from typing import Annotated, Any
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import settings
|
||
from app.database import get_db
|
||
from app.models import AutomationHook
|
||
from app.utils.automation_hooks import SAMPLE_PAYLOADS
|
||
from app.utils.webhook import VALID_EVENTS
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/automation", tags=["automation"])
|
||
|
||
DbSession = Annotated[Session, Depends(get_db)]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Auth helper – require a valid API token (Bearer)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _require_api_user(request: Request) -> dict:
|
||
"""Ensure the caller is authenticated via session or API token.
|
||
|
||
Raises:
|
||
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
|
||
"""
|
||
if not settings.automation_hooks_enabled:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Automation hooks are disabled",
|
||
)
|
||
|
||
# Check for API-token user first (set by auth middleware)
|
||
user = getattr(request.state, "api_token_user", None)
|
||
if user:
|
||
return user
|
||
|
||
# Fall back to session user
|
||
user = request.session.get("user")
|
||
if user:
|
||
return user
|
||
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Authentication required (Bearer token or session)",
|
||
)
|
||
|
||
|
||
AuthUser = Annotated[dict, Depends(_require_api_user)]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pydantic schemas
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class HookSubscribe(BaseModel):
|
||
"""Schema for subscribing to automation hook events."""
|
||
|
||
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
|
||
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
|
||
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
|
||
hook_type: str = Field(
|
||
default="generic",
|
||
max_length=50,
|
||
description="Platform identifier (zapier, make, generic)",
|
||
)
|
||
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
|
||
|
||
|
||
class HookResponse(BaseModel):
|
||
"""Schema returned when listing or creating hooks."""
|
||
|
||
id: int
|
||
target_url: str
|
||
events: list[str]
|
||
is_active: bool
|
||
hook_type: str
|
||
description: str | None
|
||
has_secret: bool
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class ActionUploadResponse(BaseModel):
|
||
"""Response after an automation action uploads a document."""
|
||
|
||
status: str
|
||
filename: str
|
||
task_id: str | None = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _validate_events(events: list[str]) -> None:
|
||
"""Raise 422 if any event name is not recognised."""
|
||
invalid = set(events) - VALID_EVENTS
|
||
if invalid:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||
)
|
||
|
||
|
||
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
|
||
"""Convert a DB model instance to a response dict."""
|
||
try:
|
||
events = json.loads(hook.events)
|
||
except (json.JSONDecodeError, TypeError):
|
||
events = []
|
||
return {
|
||
"id": hook.id,
|
||
"target_url": hook.target_url,
|
||
"events": events,
|
||
"is_active": hook.is_active,
|
||
"hook_type": hook.hook_type,
|
||
"description": hook.description,
|
||
"has_secret": hook.secret is not None and len(hook.secret) > 0,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Outgoing triggers – REST hooks subscription endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post(
|
||
"/hooks/subscribe",
|
||
status_code=status.HTTP_201_CREATED,
|
||
summary="Subscribe to automation events (REST hooks)",
|
||
)
|
||
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
|
||
"""Register a new automation hook subscription.
|
||
|
||
Zapier and Make.com call this endpoint to subscribe to DocuElevate
|
||
events. When an event fires, a flat JSON payload is POSTed to
|
||
``target_url``.
|
||
"""
|
||
_validate_events(body.events)
|
||
|
||
hook = AutomationHook(
|
||
target_url=body.target_url,
|
||
secret=body.secret,
|
||
events=json.dumps(sorted(body.events)),
|
||
is_active=True,
|
||
hook_type=body.hook_type or "generic",
|
||
description=body.description,
|
||
)
|
||
try:
|
||
db.add(hook)
|
||
db.commit()
|
||
db.refresh(hook)
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
|
||
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
|
||
return _hook_to_response(hook)
|
||
|
||
|
||
@router.delete(
|
||
"/hooks/{hook_id}",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
summary="Unsubscribe an automation hook",
|
||
)
|
||
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
|
||
"""Remove an automation hook subscription.
|
||
|
||
Zapier calls this endpoint when a Zap is turned off or deleted.
|
||
"""
|
||
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
|
||
if not hook:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
|
||
|
||
try:
|
||
db.delete(hook)
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
|
||
logger.info("Automation hook %d deleted", hook_id)
|
||
|
||
|
||
@router.get("/hooks", summary="List automation hook subscriptions")
|
||
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
|
||
"""Return all active automation hook subscriptions."""
|
||
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
|
||
return [_hook_to_response(h) for h in hooks]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Outgoing triggers – sample data for Zapier field mapping
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
|
||
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
|
||
"""Return sample payload data for the given event type.
|
||
|
||
Zapier uses this during Zap setup to discover available fields and
|
||
provide a mapping interface. The response is wrapped in an array
|
||
as Zapier expects.
|
||
"""
|
||
if event not in VALID_EVENTS:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||
)
|
||
|
||
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
|
||
return [sample]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Outgoing triggers – list valid events
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/events", summary="List valid automation event types")
|
||
def list_events(user: AuthUser) -> list[str]:
|
||
"""Return the list of valid event types that automation hooks can subscribe to."""
|
||
return sorted(VALID_EVENTS)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Incoming actions – endpoints that Zapier / Make.com can call
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/actions/upload", summary="Upload a document (incoming action)")
|
||
def action_upload(
|
||
request: Request,
|
||
db: DbSession,
|
||
user: AuthUser,
|
||
file: UploadFile = File(...),
|
||
) -> dict[str, Any]:
|
||
"""Accept a document upload from an automation platform.
|
||
|
||
This endpoint allows Zapier or Make.com to push a document into
|
||
DocuElevate for processing. The file is saved to the work directory
|
||
and a background processing task is queued.
|
||
"""
|
||
if not file.filename:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||
|
||
# Sanitise filename to prevent path traversal attacks
|
||
safe_filename = os.path.basename(file.filename)
|
||
if not safe_filename:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||
|
||
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
|
||
workdir = settings.workdir or tempfile.gettempdir()
|
||
upload_dir = os.path.join(workdir, "uploads")
|
||
os.makedirs(upload_dir, exist_ok=True)
|
||
|
||
dest_path = os.path.join(upload_dir, safe_filename)
|
||
try:
|
||
contents = file.file.read()
|
||
with open(dest_path, "wb") as f:
|
||
f.write(contents)
|
||
except Exception as exc:
|
||
logger.error("Failed to save uploaded file: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
|
||
|
||
# Queue background processing
|
||
task_id = None
|
||
try:
|
||
from app.tasks.process_document import process_document
|
||
|
||
result = process_document.delay(dest_path, owner_id)
|
||
task_id = result.id
|
||
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", safe_filename, task_id, owner_id)
|
||
except Exception as exc:
|
||
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
|
||
|
||
return {
|
||
"status": "accepted",
|
||
"filename": safe_filename,
|
||
"task_id": task_id,
|
||
}
|