128 lines
4.0 KiB
Python
128 lines
4.0 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 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
|
|
|
|
|
|
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)
|