"""Signed, bounded, durable event delivery; independent of frame writes.""" import hashlib import hmac import json import secrets import time from datetime import UTC, datetime, timedelta from uuid import uuid4 from fastapi import Depends from pydantic import Field from pigway_frame_flow.core.auth import require from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import StrictModel from pigway_frame_flow.storage.database import encode, now_iso class WebhookInput(StrictModel): callback_url: str = Field(max_length=2048) event_types: list[str] = Field(min_length=1, max_length=20) enabled: bool = True class Webhooks: def __init__(self, service): self.service = service self.last = 0.0 service.db.conn.executescript("""CREATE TABLE IF NOT EXISTS webhook_deliveries ( subscription_id TEXT,event_id TEXT,status TEXT,attempts INTEGER DEFAULT 0,next_at TEXT,error_code TEXT, PRIMARY KEY(subscription_id,event_id));""") def tick(self): if time.monotonic() - self.last < 60: return self.last = time.monotonic() db = self.service.db for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"): types = json.loads(sub["event_types"]) if not types: continue placeholders = ",".join("?" for _ in types) for event in db.all( f"SELECT * FROM events e WHERE created_at>=? AND event_type IN ({placeholders}) AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500", (sub["created_at"], *types, sub["id"]), ): db.execute( "INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)", (sub["id"], event["id"], now_iso()), ) for row in db.all( "SELECT d.*,s.callback_url FROM webhook_deliveries d JOIN webhook_subscriptions s ON s.id=d.subscription_id WHERE s.enabled=1 AND d.status IN ('QUEUED','RETRY') AND d.next_at<=? ORDER BY d.next_at LIMIT 2", (now_iso(),), ): event = db.one("SELECT * FROM events WHERE id=?", (row["event_id"],)) if not event: continue keyfile = self.service.settings.config_dir / ( "webhook-" + row["subscription_id"] + ".secret" ) try: key = keyfile.read_text().strip() payload = encode( { "id": event["id"], "type": event["event_type"], "timestamp": event["created_at"], "data": json.loads(event["payload"]), } ).encode() signature = hmac.new(key.encode(), payload, hashlib.sha256).hexdigest() self.service.assets.fetch_url( row["callback_url"], method="POST", body=payload, headers={ "Content-Type": "application/json", "X-Frame-Flow-Event-ID": event["id"], "X-Frame-Flow-Signature": "sha256=" + signature, }, limit=64 * 1024, accepted_status=(200, 201, 202, 204), ) status, error = "DELIVERED", None except Exception: status, error = ( ("FAILED" if row["attempts"] >= 2 else "RETRY"), "DELIVERY_FAILED", ) db.execute( "UPDATE webhook_deliveries SET status=?,attempts=attempts+1,next_at=?,error_code=? WHERE subscription_id=? AND event_id=?", ( status, ( datetime.now(UTC) + timedelta(seconds=60 * 2 ** row["attempts"]) ).isoformat(), error, row["subscription_id"], row["event_id"], ), ) def install_webhooks(app, service): worker = Webhooks(service) service.webhooks = worker db = service.db @app.get( "/api/v1/webhooks", tags=["Webhooks"], dependencies=[Depends(require("admin"))] ) def list_hooks(): return [dict(row) for row in db.all("SELECT * FROM webhook_subscriptions")] @app.post( "/api/v1/webhooks", status_code=201, tags=["Webhooks"], dependencies=[Depends(require("admin"))], ) def add_hook(payload: WebhookInput): from urllib.parse import urlsplit parsed = urlsplit(payload.callback_url) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.port not in (None, 443) or parsed.hostname not in service.settings.image_url_hosts ): raise FlowError( E.INVALID_REQUEST, "Webhook requires an explicitly allowed HTTPS host" ) allowed = { "operation.completed", "operation.failed", "display.changed", "device.online", "device.offline", "device.low_battery", } if set(payload.event_types) - allowed: raise FlowError(E.INVALID_REQUEST, "Unsupported event type") id = uuid4().hex key = secrets.token_urlsafe(32) import os path = service.settings.config_dir / ("webhook-" + id + ".secret") with os.fdopen( os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w" ) as f: f.write(key) db.execute( "INSERT INTO webhook_subscriptions VALUES(?,?,?,?,?,?)", ( id, "admin", payload.callback_url, encode(payload.event_types), int(payload.enabled), now_iso(), ), ) service.settings.additional_secrets.append(key) return {"id": id, "signing_secret": key} @app.delete( "/api/v1/webhooks/{id}", status_code=204, tags=["Webhooks"], dependencies=[Depends(require("admin"))], ) def delete_hook(id: str): if not db.one("SELECT id FROM webhook_subscriptions WHERE id=?", (id,)): raise FlowError(E.NOT_FOUND, "Webhook not found", 404) db.execute("DELETE FROM webhook_subscriptions WHERE id=?", (id,)) (service.settings.config_dir / ("webhook-" + id + ".secret")).unlink( missing_ok=True ) @app.get( "/api/v1/webhooks/{id}/deliveries", tags=["Webhooks"], dependencies=[Depends(require("admin"))], ) def deliveries(id: str): return [ dict(row) for row in db.all( "SELECT * FROM webhook_deliveries WHERE subscription_id=? ORDER BY next_at DESC LIMIT 100", (id,), ) ]