ObsiGate/backend/webhooks.py

132 lines
4.1 KiB
Python

"""
Webhook management and dispatch for ObsiGate.
Webhooks are HTTP POST callbacks triggered on file/directory events.
Configuration is persisted in data/webhooks.json.
Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
"""
import json
import os
import hmac
import hashlib
import asyncio
import logging
import uuid
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, List
import aiohttp
from datetime import datetime, timezone
from typing import Optional, List
import aiohttp
logger = logging.getLogger("obsigate.webhooks")
WEBHOOKS_FILE = Path("data/webhooks.json")
VALID_EVENTS = {
"file_created", "file_deleted", "file_modified", "file_renamed",
"directory_created", "directory_deleted", "directory_renamed",
}
def _read() -> list:
if not WEBHOOKS_FILE.exists():
return []
try:
return json.loads(WEBHOOKS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
def _write(webhooks: list):
WEBHOOKS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = WEBHOOKS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(webhooks, indent=2, default=str), encoding="utf-8")
tmp.replace(WEBHOOKS_FILE)
def get_webhooks() -> list:
return _read()
def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict:
webhooks = _read()
wh = {
"id": str(uuid.uuid4()),
"name": name,
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"secret": secret,
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
}
webhooks.append(wh)
_write(webhooks)
logger.info(f"Created webhook '{name}'{url}")
return wh
def update_webhook(wh_id: str, updates: dict) -> Optional[dict]:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
wh.update({k: v for k, v in updates.items() if k != "id"})
_write(webhooks)
return wh
return None
def delete_webhook(wh_id: str) -> bool:
webhooks = _read()
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
if len(new_list) == len(webhooks):
return False
_write(new_list)
return True
async def dispatch_webhooks(event_type: str, data: dict):
"""Fire all enabled webhooks subscribed to event_type."""
webhooks = _read()
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
if not targets:
return
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
async def _post(wh):
try:
headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type}
if wh.get("secret"):
sig = hmac.new(wh["secret"].encode(), body.encode(), hashlib.sha256).hexdigest()
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(wh["url"], data=body, headers=headers) as resp:
if resp.status < 400:
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
tasks = [asyncio.create_task(_post(wh)) for wh in targets]
# Don't await — fire and forget (webhooks should not block the main thread)
# But we do register them so they run in background
for task in tasks:
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)