feat: add Gitea as webhook-based service provider
First webhook-based provider integration (Immich uses polling).
Gitea pushes events via POST /api/webhooks/gitea/{provider_id} with
HMAC-SHA256 signature validation.
- 9 event types: push, issue opened/closed/commented, PR opened/closed/merged/commented, release published
- Generic filters system on NotificationTracker (collections, senders, exclude_senders)
- Provider capabilities include supported_filters and webhook_based flag
- Gitea API client for connection testing and repository listing
- 18 default Jinja2 notification templates (EN + RU)
- Frontend: conditional provider forms, Gitea event toggles in tracking config
- Auto-migration for filters column and Gitea tracking flags
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"""Incoming webhook handlers for webhook-based providers (Gitea, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
|
||||
from notify_bridge_core.providers.gitea.event_parser import parse_webhook as parse_gitea_webhook
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
EmailBot,
|
||||
EventLog,
|
||||
MatrixBot,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TargetReceiver,
|
||||
TemplateConfig,
|
||||
TemplateSlot,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HMAC-SHA256 validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _verify_gitea_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||
"""Verify Gitea X-Gitea-Signature HMAC-SHA256."""
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _passes_filters(
|
||||
event: ServiceEvent,
|
||||
filters: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if an event passes the tracker's filters."""
|
||||
# Collection filter (repo full_name for Gitea)
|
||||
collections = filters.get("collections", [])
|
||||
if collections and event.collection_id not in collections:
|
||||
return False
|
||||
|
||||
sender = event.extra.get("sender", "")
|
||||
# Sender allowlist
|
||||
senders = filters.get("senders", [])
|
||||
if senders and sender not in senders:
|
||||
return False
|
||||
|
||||
# Sender blocklist
|
||||
exclude_senders = filters.get("exclude_senders", [])
|
||||
if exclude_senders and sender in exclude_senders:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea webhook endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/gitea/{provider_id}")
|
||||
async def gitea_webhook(provider_id: int, request: Request):
|
||||
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
|
||||
# --- Load provider and validate signature ---
|
||||
async with AsyncSession(engine) as session:
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.type != "gitea":
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
||||
|
||||
# Read raw body for HMAC check
|
||||
raw_body = await request.body()
|
||||
|
||||
if webhook_secret:
|
||||
signature = request.headers.get("X-Gitea-Signature", "")
|
||||
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
# Parse event header + payload
|
||||
event_header = request.headers.get("X-Gitea-Event", "")
|
||||
if not event_header:
|
||||
return {"ok": True, "skipped": "no event header"}
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||
|
||||
event = parse_gitea_webhook(event_header, payload, provider.name)
|
||||
if event is None:
|
||||
return {"ok": True, "skipped": "unmapped event"}
|
||||
|
||||
# --- Find trackers for this provider and dispatch ---
|
||||
dispatched = 0
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker_result = await session.exec(
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.provider_id == provider_id,
|
||||
NotificationTracker.enabled == True,
|
||||
)
|
||||
)
|
||||
trackers = tracker_result.all()
|
||||
|
||||
for tracker in trackers:
|
||||
# Apply filters
|
||||
filters = tracker.filters or {}
|
||||
if not _passes_filters(event, filters):
|
||||
_LOGGER.debug(
|
||||
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
||||
)
|
||||
continue
|
||||
|
||||
# Load tracker-target links
|
||||
link_data = await _load_link_data(session, tracker.id)
|
||||
if not link_data:
|
||||
continue
|
||||
|
||||
# Log event
|
||||
session.add(EventLog(
|
||||
tracker_id=tracker.id,
|
||||
tracker_name=tracker.name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider.name,
|
||||
event_type=event.event_type.value,
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=0,
|
||||
details={
|
||||
"provider_type": event.provider_type.value,
|
||||
**{k: v for k, v in event.extra.items() if k in (
|
||||
"sender", "branch", "commit_count",
|
||||
"issue_number", "issue_title",
|
||||
"pr_number", "pr_title",
|
||||
"release_tag", "release_name",
|
||||
)},
|
||||
},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
# Dispatch to targets
|
||||
dispatcher = NotificationDispatcher()
|
||||
target_configs = _build_target_configs(event, link_data, provider.config or {})
|
||||
if target_configs:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
if r.get("success"):
|
||||
dispatched += 1
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Notification failed for tracker %d: %s",
|
||||
tracker.id, r.get("error", "unknown"),
|
||||
)
|
||||
|
||||
return {"ok": True, "dispatched": dispatched}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared dispatch helpers (extracted from watcher pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _load_link_data(
|
||||
session: AsyncSession,
|
||||
tracker_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load tracker-target link data for dispatch (same pattern as watcher)."""
|
||||
tt_result = await session.exec(
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
tracker_targets = tt_result.all()
|
||||
|
||||
link_data: list[dict[str, Any]] = []
|
||||
for tt in tracker_targets:
|
||||
if not tt.enabled:
|
||||
continue
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Load receivers
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.enabled == True,
|
||||
)
|
||||
)
|
||||
receivers = [dict(r.config) for r in recv_result.all()]
|
||||
|
||||
tracking_config = None
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
|
||||
template_config = None
|
||||
template_slots: dict[str, str] | None = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
if template_config:
|
||||
slot_result = await session.exec(
|
||||
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
|
||||
)
|
||||
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
|
||||
template_slots = {}
|
||||
for slot_name, tmpl_text in raw_slots.items():
|
||||
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
|
||||
template_slots[event_key] = tmpl_text
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject bot credentials
|
||||
if target.type == "email":
|
||||
email_bot_id = target.config.get("email_bot_id")
|
||||
if email_bot_id:
|
||||
email_bot = await session.get(EmailBot, email_bot_id)
|
||||
if email_bot:
|
||||
target_config["smtp"] = {
|
||||
"host": email_bot.smtp_host,
|
||||
"port": email_bot.smtp_port,
|
||||
"username": email_bot.smtp_username,
|
||||
"password": email_bot.smtp_password,
|
||||
"from_address": email_bot.email,
|
||||
"from_name": email_bot.name,
|
||||
"use_tls": email_bot.smtp_use_tls,
|
||||
}
|
||||
elif target.type == "matrix":
|
||||
matrix_bot_id = target.config.get("matrix_bot_id")
|
||||
if matrix_bot_id:
|
||||
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
||||
if matrix_bot:
|
||||
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
||||
target_config["access_token"] = matrix_bot.access_token
|
||||
|
||||
link_data.append({
|
||||
"target_type": target.type,
|
||||
"target_config": target_config,
|
||||
"receivers": receivers,
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
"template_slots": template_slots,
|
||||
})
|
||||
|
||||
return link_data
|
||||
|
||||
|
||||
def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by tracking config flags."""
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
"push": tc.track_push,
|
||||
"issue_opened": tc.track_issue_opened,
|
||||
"issue_closed": tc.track_issue_closed,
|
||||
"issue_commented": tc.track_issue_commented,
|
||||
"pr_opened": tc.track_pr_opened,
|
||||
"pr_closed": tc.track_pr_closed,
|
||||
"pr_merged": tc.track_pr_merged,
|
||||
"pr_commented": tc.track_pr_commented,
|
||||
"release_published": tc.track_release_published,
|
||||
# Immich events
|
||||
"assets_added": tc.track_assets_added,
|
||||
"assets_removed": tc.track_assets_removed,
|
||||
"collection_renamed": tc.track_collection_renamed,
|
||||
"collection_deleted": tc.track_collection_deleted,
|
||||
"sharing_changed": tc.track_sharing_changed,
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
def _build_target_configs(
|
||||
event: ServiceEvent,
|
||||
link_data: list[dict[str, Any]],
|
||||
provider_config: dict[str, Any],
|
||||
) -> list[TargetConfig]:
|
||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
||||
target_configs: list[TargetConfig] = []
|
||||
for ld in link_data:
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not _event_allowed_by_tracking_config(event, tc):
|
||||
continue
|
||||
|
||||
tmpl = ld["template_config"]
|
||||
target_configs.append(TargetConfig(
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=ld["template_slots"],
|
||||
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
|
||||
provider_api_key=provider_config.get("api_token"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("url", ""),
|
||||
receivers=ld["receivers"],
|
||||
))
|
||||
return target_configs
|
||||
Reference in New Issue
Block a user