Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
346 lines
10 KiB
Python
346 lines
10 KiB
Python
"""CRUD operations for webhooks."""
|
|
|
|
import json
|
|
import secrets
|
|
import hashlib
|
|
import hmac
|
|
import httpx
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, List, Any
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.webhook import Webhook, WebhookDelivery
|
|
from app.schemas.webhook import WebhookCreate, WebhookUpdate
|
|
|
|
|
|
class CRUDWebhook:
|
|
"""CRUD operations for webhooks."""
|
|
|
|
def get(self, db: Session, id: str) -> Optional[Webhook]:
|
|
"""Get a webhook by ID."""
|
|
return db.query(Webhook).filter(Webhook.id == id).first()
|
|
|
|
def get_multi(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
is_active: Optional[bool] = None
|
|
) -> List[Webhook]:
|
|
"""Get multiple webhooks."""
|
|
query = db.query(Webhook)
|
|
if is_active is not None:
|
|
query = query.filter(Webhook.is_active == is_active)
|
|
return query.order_by(Webhook.created_at.desc()).offset(skip).limit(limit).all()
|
|
|
|
def get_by_event(self, db: Session, event_type: str) -> List[Webhook]:
|
|
"""Get all active webhooks that subscribe to an event type."""
|
|
webhooks = db.query(Webhook).filter(Webhook.is_active == True).all()
|
|
matching = []
|
|
for webhook in webhooks:
|
|
events = json.loads(webhook.events) if webhook.events else []
|
|
if "*" in events or event_type in events:
|
|
matching.append(webhook)
|
|
return matching
|
|
|
|
def create(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
obj_in: WebhookCreate,
|
|
created_by: Optional[str] = None
|
|
) -> Webhook:
|
|
"""Create a new webhook with a generated secret."""
|
|
# Generate a secret for signature verification
|
|
secret = secrets.token_hex(32)
|
|
|
|
db_obj = Webhook(
|
|
name=obj_in.name,
|
|
url=obj_in.url,
|
|
secret=secret,
|
|
events=json.dumps(obj_in.events),
|
|
is_active=obj_in.is_active,
|
|
retry_count=obj_in.retry_count,
|
|
timeout_seconds=obj_in.timeout_seconds,
|
|
created_by=created_by
|
|
)
|
|
db.add(db_obj)
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
def update(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
db_obj: Webhook,
|
|
obj_in: WebhookUpdate
|
|
) -> Webhook:
|
|
"""Update a webhook."""
|
|
update_data = obj_in.model_dump(exclude_unset=True)
|
|
|
|
if "events" in update_data:
|
|
update_data["events"] = json.dumps(update_data["events"])
|
|
|
|
for field, value in update_data.items():
|
|
setattr(db_obj, field, value)
|
|
|
|
db.add(db_obj)
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
def delete(self, db: Session, *, id: str) -> Optional[Webhook]:
|
|
"""Delete a webhook."""
|
|
obj = db.query(Webhook).filter(Webhook.id == id).first()
|
|
if obj:
|
|
db.delete(obj)
|
|
db.commit()
|
|
return obj
|
|
|
|
def regenerate_secret(self, db: Session, *, db_obj: Webhook) -> Webhook:
|
|
"""Regenerate the webhook secret."""
|
|
db_obj.secret = secrets.token_hex(32)
|
|
db.add(db_obj)
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
def count(self, db: Session) -> int:
|
|
"""Count total webhooks."""
|
|
return db.query(Webhook).count()
|
|
|
|
|
|
class CRUDWebhookDelivery:
|
|
"""CRUD operations for webhook deliveries."""
|
|
|
|
def get(self, db: Session, id: str) -> Optional[WebhookDelivery]:
|
|
"""Get a delivery by ID."""
|
|
return db.query(WebhookDelivery).filter(WebhookDelivery.id == id).first()
|
|
|
|
def get_by_webhook(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
webhook_id: str,
|
|
skip: int = 0,
|
|
limit: int = 50
|
|
) -> List[WebhookDelivery]:
|
|
"""Get deliveries for a specific webhook."""
|
|
return (
|
|
db.query(WebhookDelivery)
|
|
.filter(WebhookDelivery.webhook_id == webhook_id)
|
|
.order_by(WebhookDelivery.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
def get_pending_retries(self, db: Session) -> List[WebhookDelivery]:
|
|
"""Get deliveries that need to be retried."""
|
|
now = datetime.utcnow()
|
|
return (
|
|
db.query(WebhookDelivery)
|
|
.filter(
|
|
WebhookDelivery.status == "failed",
|
|
WebhookDelivery.next_retry_at <= now
|
|
)
|
|
.all()
|
|
)
|
|
|
|
def create(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
webhook_id: str,
|
|
event_type: str,
|
|
payload: dict
|
|
) -> WebhookDelivery:
|
|
"""Create a new webhook delivery record."""
|
|
db_obj = WebhookDelivery(
|
|
webhook_id=webhook_id,
|
|
event_type=event_type,
|
|
payload=json.dumps(payload),
|
|
status="pending"
|
|
)
|
|
db.add(db_obj)
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
def update_status(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
db_obj: WebhookDelivery,
|
|
status: str,
|
|
status_code: Optional[int] = None,
|
|
response_body: Optional[str] = None,
|
|
error_message: Optional[str] = None,
|
|
schedule_retry: bool = False,
|
|
max_retries: int = 3
|
|
) -> WebhookDelivery:
|
|
"""Update delivery status."""
|
|
db_obj.status = status
|
|
db_obj.status_code = status_code
|
|
db_obj.response_body = response_body[:1000] if response_body else None
|
|
db_obj.error_message = error_message
|
|
db_obj.attempt_count += 1
|
|
|
|
if status == "success":
|
|
db_obj.delivered_at = datetime.utcnow()
|
|
db_obj.next_retry_at = None
|
|
elif status == "failed" and schedule_retry and db_obj.attempt_count < max_retries:
|
|
# Exponential backoff: 1min, 5min, 30min
|
|
delays = [60, 300, 1800]
|
|
delay = delays[min(db_obj.attempt_count - 1, len(delays) - 1)]
|
|
db_obj.next_retry_at = datetime.utcnow() + timedelta(seconds=delay)
|
|
else:
|
|
db_obj.next_retry_at = None
|
|
|
|
db.add(db_obj)
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
|
|
class WebhookService:
|
|
"""Service for triggering and delivering webhooks."""
|
|
|
|
def __init__(self):
|
|
self.webhook_crud = CRUDWebhook()
|
|
self.delivery_crud = CRUDWebhookDelivery()
|
|
|
|
def generate_signature(self, payload: str, secret: str) -> str:
|
|
"""Generate HMAC-SHA256 signature for payload."""
|
|
return hmac.new(
|
|
secret.encode(),
|
|
payload.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()
|
|
|
|
async def trigger_event(
|
|
self,
|
|
db: Session,
|
|
event_type: str,
|
|
payload: dict
|
|
) -> List[WebhookDelivery]:
|
|
"""Trigger webhooks for an event."""
|
|
webhooks = self.webhook_crud.get_by_event(db, event_type)
|
|
deliveries = []
|
|
|
|
for webhook in webhooks:
|
|
delivery = self.delivery_crud.create(
|
|
db,
|
|
webhook_id=webhook.id,
|
|
event_type=event_type,
|
|
payload=payload
|
|
)
|
|
deliveries.append(delivery)
|
|
|
|
# Attempt delivery
|
|
await self.deliver(db, webhook, delivery)
|
|
|
|
return deliveries
|
|
|
|
async def deliver(
|
|
self,
|
|
db: Session,
|
|
webhook: Webhook,
|
|
delivery: WebhookDelivery
|
|
) -> bool:
|
|
"""Deliver a webhook."""
|
|
payload_str = delivery.payload
|
|
signature = self.generate_signature(payload_str, webhook.secret)
|
|
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Webhook-Signature": signature,
|
|
"X-Webhook-Event": delivery.event_type,
|
|
"X-Webhook-Delivery-Id": delivery.id
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
|
|
response = await client.post(
|
|
webhook.url,
|
|
content=payload_str,
|
|
headers=headers
|
|
)
|
|
|
|
if response.status_code >= 200 and response.status_code < 300:
|
|
self.delivery_crud.update_status(
|
|
db,
|
|
db_obj=delivery,
|
|
status="success",
|
|
status_code=response.status_code,
|
|
response_body=response.text
|
|
)
|
|
webhook.success_count += 1
|
|
webhook.last_triggered_at = datetime.utcnow()
|
|
db.add(webhook)
|
|
db.commit()
|
|
return True
|
|
else:
|
|
self.delivery_crud.update_status(
|
|
db,
|
|
db_obj=delivery,
|
|
status="failed",
|
|
status_code=response.status_code,
|
|
response_body=response.text,
|
|
error_message=f"HTTP {response.status_code}",
|
|
schedule_retry=True,
|
|
max_retries=webhook.retry_count
|
|
)
|
|
webhook.failure_count += 1
|
|
db.add(webhook)
|
|
db.commit()
|
|
return False
|
|
|
|
except Exception as e:
|
|
self.delivery_crud.update_status(
|
|
db,
|
|
db_obj=delivery,
|
|
status="failed",
|
|
error_message=str(e),
|
|
schedule_retry=True,
|
|
max_retries=webhook.retry_count
|
|
)
|
|
webhook.failure_count += 1
|
|
db.add(webhook)
|
|
db.commit()
|
|
return False
|
|
|
|
async def test_webhook(
|
|
self,
|
|
db: Session,
|
|
webhook: Webhook,
|
|
event_type: str = "test.ping",
|
|
payload: Optional[dict] = None
|
|
) -> WebhookDelivery:
|
|
"""Send a test delivery to a webhook."""
|
|
if payload is None:
|
|
payload = {
|
|
"event": event_type,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"test": True,
|
|
"message": "This is a test webhook delivery"
|
|
}
|
|
|
|
delivery = self.delivery_crud.create(
|
|
db,
|
|
webhook_id=webhook.id,
|
|
event_type=event_type,
|
|
payload=payload
|
|
)
|
|
|
|
await self.deliver(db, webhook, delivery)
|
|
return delivery
|
|
|
|
|
|
# Singleton instances
|
|
webhook = CRUDWebhook()
|
|
webhook_delivery = CRUDWebhookDelivery()
|
|
webhook_service = WebhookService()
|