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:
@@ -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') },
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Только избранные",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let providers = $derived(providersCache.items);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(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 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if form.type === 'immich'}
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
@@ -139,6 +153,18 @@
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{:else if form.type === 'gitea'}
|
||||
<div>
|
||||
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
|
||||
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookSecretHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-token" class="block text-sm font-medium mb-1">{t('providers.apiToken')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-token" bind:value={form.api_token} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.apiTokenHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', icon: '',
|
||||
// Immich event tracking
|
||||
track_assets_added: true, track_assets_removed: false,
|
||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
@@ -39,6 +40,10 @@
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
// Gitea event tracking
|
||||
track_push: true, track_issue_opened: true, track_issue_closed: true, track_issue_commented: false,
|
||||
track_pr_opened: true, track_pr_closed: true, track_pr_merged: true, track_pr_commented: false,
|
||||
track_release_published: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
@@ -112,6 +117,19 @@
|
||||
<!-- Event tracking -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
{#if form.provider_type === 'gitea'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_push} /> {t('trackingConfig.push')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_opened} /> {t('trackingConfig.issueOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_closed} /> {t('trackingConfig.issueClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_commented} /> {t('trackingConfig.issueCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_opened} /> {t('trackingConfig.prOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_closed} /> {t('trackingConfig.prClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_merged} /> {t('trackingConfig.prMerged')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_commented} /> {t('trackingConfig.prCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_release_published} /> {t('trackingConfig.releasePublished')}</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
@@ -124,6 +142,8 @@
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if form.provider_type !== 'gitea'}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
||||
@@ -138,8 +158,10 @@
|
||||
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if form.provider_type !== 'gitea'}
|
||||
<!-- Periodic summary -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||
@@ -190,6 +212,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
|
||||
Reference in New Issue
Block a user