From 6d28cfb8d86414a675e5dadcd3be524d8d1b540f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 22 Mar 2026 12:58:35 +0300 Subject: [PATCH] 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 --- frontend/src/lib/grid-items.ts | 1 + frontend/src/lib/i18n/en.json | 17 + frontend/src/lib/i18n/ru.json | 17 + frontend/src/routes/providers/+page.svelte | 40 ++- .../src/routes/tracking-configs/+page.svelte | 23 ++ .../src/notify_bridge_core/models/events.py | 12 + .../src/notify_bridge_core/providers/base.py | 1 + .../providers/capabilities.py | 64 ++++ .../providers/gitea/__init__.py | 19 ++ .../providers/gitea/client.py | 89 +++++ .../providers/gitea/event_parser.py | 221 ++++++++++++ .../providers/gitea/models.py | 186 +++++++++++ .../providers/gitea/provider.py | 270 +++++++++++++++ .../defaults/en/gitea_issue_closed.jinja2 | 2 + .../defaults/en/gitea_issue_commented.jinja2 | 3 + .../defaults/en/gitea_issue_opened.jinja2 | 5 + .../defaults/en/gitea_pr_closed.jinja2 | 2 + .../defaults/en/gitea_pr_commented.jinja2 | 3 + .../defaults/en/gitea_pr_merged.jinja2 | 3 + .../defaults/en/gitea_pr_opened.jinja2 | 6 + .../templates/defaults/en/gitea_push.jinja2 | 9 + .../en/gitea_release_published.jinja2 | 7 + .../templates/defaults/loader.py | 47 ++- .../defaults/ru/gitea_issue_closed.jinja2 | 2 + .../defaults/ru/gitea_issue_commented.jinja2 | 3 + .../defaults/ru/gitea_issue_opened.jinja2 | 5 + .../defaults/ru/gitea_pr_closed.jinja2 | 2 + .../defaults/ru/gitea_pr_commented.jinja2 | 3 + .../defaults/ru/gitea_pr_merged.jinja2 | 3 + .../defaults/ru/gitea_pr_opened.jinja2 | 6 + .../templates/defaults/ru/gitea_push.jinja2 | 9 + .../ru/gitea_release_published.jinja2 | 7 + .../src/notify_bridge_server/api/providers.py | 59 +++- .../src/notify_bridge_server/api/webhooks.py | 314 ++++++++++++++++++ .../database/migrations.py | 28 ++ .../notify_bridge_server/database/models.py | 14 + .../server/src/notify_bridge_server/main.py | 84 ++++- .../notify_bridge_server/services/__init__.py | 12 + .../notify_bridge_server/services/watcher.py | 15 + 39 files changed, 1588 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/notify_bridge_core/providers/gitea/__init__.py create mode 100644 packages/core/src/notify_bridge_core/providers/gitea/client.py create mode 100644 packages/core/src/notify_bridge_core/providers/gitea/event_parser.py create mode 100644 packages/core/src/notify_bridge_core/providers/gitea/models.py create mode 100644 packages/core/src/notify_bridge_core/providers/gitea/provider.py create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_issue_closed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_issue_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_issue_opened.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_pr_closed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_pr_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_pr_merged.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_pr_opened.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_push.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gitea_release_published.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_issue_closed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_issue_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_issue_opened.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_pr_closed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_pr_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_pr_merged.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_pr_opened.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_push.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gitea_release_published.jinja2 create mode 100644 packages/server/src/notify_bridge_server/api/webhooks.py diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index dab3ff8..2ab6404 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -99,4 +99,5 @@ export const previewTargetTypeItems = (): GridItem[] => [ export const providerTypeItems = (): GridItem[] => [ { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') }, + { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea') }, ]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 84e8970..e76bae3 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -106,11 +106,18 @@ "offline": "Offline", "checking": "Checking...", "typeImmich": "Immich", + "typeGitea": "Gitea", "loadError": "Failed to load providers.", "externalDomain": "External Domain", "optional": "optional", "urlApiKeyRequired": "URL and API Key are required", "externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.", + "webhookSecret": "Webhook Secret", + "webhookSecretKeep": "Webhook Secret (leave empty to keep current)", + "webhookSecretHint": "Shared secret for HMAC-SHA256 signature verification. Set the same secret in Gitea webhook settings.", + "webhookSecretRequired": "Webhook secret is required", + "apiToken": "API Token", + "apiTokenHint": "Optional. Needed for connection testing and repository listing.", "testAndSave": "Test & Save", "saveWithoutTest": "Save without testing" }, @@ -248,6 +255,7 @@ "matrixRoomId": "Room ID", "receivers": "Receivers", "noReceivers": "No receivers yet", + "alreadyAdded": "already added", "addReceiver": "Add Receiver", "receiverAdded": "Receiver added", "receiverDeleted": "Receiver deleted", @@ -348,6 +356,15 @@ "albumRenamed": "Album renamed", "albumDeleted": "Album deleted", "sharingChanged": "Sharing changed", + "push": "Push", + "issueOpened": "Issue opened", + "issueClosed": "Issue closed", + "issueCommented": "Issue commented", + "prOpened": "PR opened", + "prClosed": "PR closed", + "prMerged": "PR merged", + "prCommented": "PR commented", + "releasePublished": "Release published", "trackImages": "Track images", "trackVideos": "Track videos", "favoritesOnly": "Favorites only", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 699dec4..97e0b3d 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -106,11 +106,18 @@ "offline": "Не в сети", "checking": "Проверка...", "typeImmich": "Immich", + "typeGitea": "Gitea", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", "optional": "необязательно", "urlApiKeyRequired": "URL и API ключ обязательны", "externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.", + "webhookSecret": "Секрет вебхука", + "webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)", + "webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.", + "webhookSecretRequired": "Секрет вебхука обязателен", + "apiToken": "API токен", + "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", "testAndSave": "Проверить и сохранить", "saveWithoutTest": "Сохранить без проверки" }, @@ -248,6 +255,7 @@ "matrixRoomId": "ID комнаты", "receivers": "Получатели", "noReceivers": "Нет получателей", + "alreadyAdded": "уже добавлен", "addReceiver": "Добавить получателя", "receiverAdded": "Получатель добавлен", "receiverDeleted": "Получатель удалён", @@ -348,6 +356,15 @@ "albumRenamed": "Альбом переименован", "albumDeleted": "Альбом удалён", "sharingChanged": "Изменение доступа", + "push": "Push", + "issueOpened": "Задача создана", + "issueClosed": "Задача закрыта", + "issueCommented": "Комментарий к задаче", + "prOpened": "PR создан", + "prClosed": "PR закрыт", + "prMerged": "PR влит", + "prCommented": "Комментарий к PR", + "releasePublished": "Релиз опубликован", "trackImages": "Фото", "trackVideos": "Видео", "favoritesOnly": "Только избранные", diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index b58e4f4..b88d204 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -21,7 +21,7 @@ let providers = $derived(providersCache.items); let showForm = $state(false); let editing = $state(null); - let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' }); + let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }); let error = $state(''); let loadError = $state(''); let submitting = $state(false); @@ -48,12 +48,16 @@ } function openNew() { - form = { name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' }; + form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }; editing = null; showForm = true; } function edit(p: any) { const cfg = p.config || {}; - form = { name: p.name, type: p.type, url: cfg.url || '', api_key: '', external_domain: cfg.external_domain || '', icon: p.icon || '' }; + form = { + name: p.name, type: p.type, url: cfg.url || '', + api_key: '', api_token: '', webhook_secret: '', + external_domain: cfg.external_domain || '', icon: p.icon || '', + }; editing = p.id; showForm = true; } @@ -61,12 +65,21 @@ e.preventDefault(); error = ''; submitting = true; try { const config: any = { url: form.url }; - if (form.api_key) config.api_key = form.api_key; - if (form.external_domain) config.external_domain = form.external_domain; + if (form.type === 'immich') { + if (form.api_key) config.api_key = form.api_key; + if (form.external_domain) config.external_domain = form.external_domain; + if (!editing) config.api_key = form.api_key; + } else if (form.type === 'gitea') { + if (form.api_token) config.api_token = form.api_token; + if (form.webhook_secret) config.webhook_secret = form.webhook_secret; + if (!editing && !form.webhook_secret) { + error = t('providers.webhookSecretRequired'); + snackError(error); submitting = false; return; + } + } if (editing) { await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) }); } else { - config.api_key = form.api_key; // required on create await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) }); } showForm = false; editing = null; providersCache.invalidate(); await load(); @@ -129,8 +142,9 @@
- +
+ {#if form.type === 'immich'}
@@ -139,6 +153,18 @@
+ {:else if form.type === 'gitea'} +
+ + +

{t('providers.webhookSecretHint')}

+
+
+ + +

{t('providers.apiTokenHint')}

+
+ {/if}