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:
2026-03-22 12:58:35 +03:00
parent 1167d138a3
commit 6d28cfb8d8
39 changed files with 1588 additions and 25 deletions
@@ -13,7 +13,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..services import make_immich_provider
from ..services import make_immich_provider, make_gitea_provider
_LOGGER = logging.getLogger(__name__)
@@ -81,6 +81,22 @@ async def create_provider(
if test_result.get("external_domain"):
config["external_domain"] = test_result["external_domain"]
elif body.type == "gitea":
config = body.config
# api_token is optional (webhook_secret is required, but token only for repo listing)
if config.get("api_token"):
async with aiohttp.ClientSession() as http_session:
from notify_bridge_core.providers.gitea import GiteaServiceProvider
gitea = GiteaServiceProvider(
http_session, config.get("url", ""), config.get("api_token", ""), body.name,
)
test_result = await gitea.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to Gitea"),
)
provider = ServiceProvider(
user_id=user.id,
type=body.type,
@@ -107,6 +123,8 @@ async def list_provider_capabilities():
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
"supported_filters": caps.supported_filters,
"webhook_based": caps.webhook_based,
}
return result
@@ -125,6 +143,8 @@ async def get_provider_capabilities(provider_type: str):
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
"supported_filters": caps.supported_filters,
"webhook_based": caps.webhook_based,
}
@@ -175,6 +195,22 @@ async def update_provider(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
elif config_changed and provider.type == "gitea":
if provider.config.get("api_token"):
try:
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
test_result = await gitea.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to Gitea"),
)
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(provider)
await session.commit()
@@ -210,6 +246,13 @@ async def test_provider(
immich = make_immich_provider(http_session, provider)
return await immich.test_connection()
if provider.type == "gitea":
if not provider.config.get("api_token"):
return {"ok": True, "message": "Gitea webhook-only mode (no API token for testing)"}
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
return await gitea.test_connection()
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@@ -227,6 +270,13 @@ async def list_collections(
immich = make_immich_provider(http_session, provider)
return await immich.list_collections()
if provider.type == "gitea":
if not provider.config.get("api_token"):
return []
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
return await gitea.list_collections()
return []
@@ -285,9 +335,10 @@ def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
# Mask sensitive fields
if "api_key" in config:
key = config["api_key"]
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
for secret_field in ("api_key", "api_token", "webhook_secret"):
if secret_field in config:
key = config[secret_field]
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
return {
"id": p.id,
"type": p.type,
@@ -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
@@ -130,6 +130,34 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added memory_source column to tracking_config table")
# Add filters JSON column to notification_tracker if missing
if await _has_table(conn, tracker_table):
if not await _has_column(conn, tracker_table, "filters"):
await conn.execute(
text(f"ALTER TABLE {tracker_table} ADD COLUMN filters TEXT DEFAULT '{{}}'")
)
logger.info("Added filters column to %s table", tracker_table)
# Add Gitea tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
gitea_flags = [
("track_push", "INTEGER DEFAULT 1"),
("track_issue_opened", "INTEGER DEFAULT 1"),
("track_issue_closed", "INTEGER DEFAULT 1"),
("track_issue_commented", "INTEGER DEFAULT 0"),
("track_pr_opened", "INTEGER DEFAULT 1"),
("track_pr_closed", "INTEGER DEFAULT 1"),
("track_pr_merged", "INTEGER DEFAULT 1"),
("track_pr_commented", "INTEGER DEFAULT 0"),
("track_release_published", "INTEGER DEFAULT 1"),
]
for col_name, col_type in gitea_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add collection_name and shared to tracker_state if missing
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
if await _has_table(conn, state_table):
@@ -115,6 +115,19 @@ class TrackingConfig(SQLModel, table=True):
track_collection_renamed: bool = Field(default=True)
track_collection_deleted: bool = Field(default=True)
track_sharing_changed: bool = Field(default=False)
# Gitea event tracking
track_push: bool = Field(default=True)
track_issue_opened: bool = Field(default=True)
track_issue_closed: bool = Field(default=True)
track_issue_commented: bool = Field(default=False)
track_pr_opened: bool = Field(default=True)
track_pr_closed: bool = Field(default=True)
track_pr_merged: bool = Field(default=True)
track_pr_commented: bool = Field(default=False)
track_release_published: bool = Field(default=True)
# Immich asset display
track_images: bool = Field(default=True)
track_videos: bool = Field(default=True)
notify_favorites_only: bool = Field(default=False)
@@ -247,6 +260,7 @@ class NotificationTracker(SQLModel, table=True):
name: str
icon: str = Field(default="")
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
scan_interval: int = Field(default=60)
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
enabled: bool = Field(default=True)
@@ -32,6 +32,7 @@ from .api.command_configs import router as command_configs_router
from .api.command_trackers import router as command_trackers_router
from .api.command_template_configs import router as command_template_configs_router
from .commands.webhook import router as webhook_router, set_webhook_secret
from .api.webhooks import router as webhooks_router
@asynccontextmanager
@@ -88,6 +89,7 @@ app.include_router(command_configs_router)
app.include_router(command_trackers_router)
app.include_router(command_template_configs_router)
app.include_router(webhook_router)
app.include_router(webhooks_router)
@app.get("/api/health")
@@ -120,7 +122,7 @@ async def _seed_default_templates():
}
for locale in ("en", "ru"):
slots = load_default_templates(locale)
slots = load_default_templates(locale, provider_type="immich")
if not slots:
continue
@@ -193,6 +195,86 @@ async def _seed_default_templates():
template=template_text,
))
# --- Seed Gitea default templates ---
gitea_result = await session.exec(
select(TemplateConfig).where(
TemplateConfig.user_id == 0,
TemplateConfig.provider_type == "gitea",
)
)
gitea_configs = gitea_result.all()
gitea_existing_locales = {
(c.locale if c.locale else "en"): c for c in gitea_configs
}
for locale in ("en", "ru"):
gitea_slots = load_default_templates(locale, provider_type="gitea")
if not gitea_slots:
continue
if locale not in gitea_existing_locales:
from datetime import datetime as _dt, timezone as _tz
now = _dt.now(_tz.utc).isoformat()
name = f"Default Gitea ({locale.upper()})"
desc = f"Default Gitea templates ({locale.upper()})"
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = "gitea"
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = ""
cols_str = ", ".join(values.keys())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
gitea_config_id = row
for slot_name, template_text in gitea_slots.items():
session.add(TemplateSlot(
config_id=gitea_config_id,
slot_name=slot_name,
template=template_text,
))
else:
config = gitea_existing_locales[locale]
for slot_name, template_text in gitea_slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
@@ -1,6 +1,7 @@
"""Shared service utilities."""
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.gitea import GiteaServiceProvider
from ..database.models import ServiceProvider
@@ -15,3 +16,14 @@ def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServi
config.get("external_domain"),
provider.name,
)
def make_gitea_provider(http_session, provider: ServiceProvider) -> GiteaServiceProvider:
"""Create a GiteaServiceProvider from a DB provider model."""
config = provider.config or {}
return GiteaServiceProvider(
http_session,
config.get("url", ""),
config.get("api_token", ""),
provider.name,
)
@@ -78,11 +78,22 @@ def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
event_type = event.event_type.value
flag_map = {
# 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,
# Gitea events
"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,
}
return flag_map.get(event_type, True)
@@ -220,6 +231,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
return {"status": "error", "reason": "failed to connect to provider"}
events, new_state = await immich.poll(collection_ids, state_dict)
elif provider_type == "gitea":
# Gitea is webhook-based — events arrive via /api/webhooks/gitea endpoint.
# The scheduler still calls check_tracker but there's nothing to poll.
return {"status": "ok", "events_detected": 0, "collections_checked": 0}
else:
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}