From 734e5c9340ec413e1289fcdbbc1852c31c340172 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 11 Apr 2026 02:14:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20UX=20improvements=20=E2=80=94=20secure?= =?UTF-8?q?=20webhooks,=20locale=20fixes,=20dynamic=20languages,=20UI=20po?= =?UTF-8?q?lish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove top paginator from dashboard events, keep only bottom - Fix test message locale: pass UI locale to email/matrix bot tests - Convert webhook auth mode from text input to icon grid selector - Generate secure UUID tokens for webhook URLs instead of sequential IDs - Move Recent Payloads into per-provider expandable container (lazy-loaded) - Make template config languages dynamic via app settings instead of hardcoded - Change default dev port to 5175 --- frontend/src/lib/grid-items.ts | 8 + frontend/src/lib/i18n/en.json | 9 + frontend/src/lib/i18n/ru.json | 9 + frontend/src/lib/providers/gitea.ts | 2 +- frontend/src/lib/providers/planka.ts | 2 +- frontend/src/lib/providers/types.ts | 5 +- frontend/src/lib/providers/webhook.ts | 8 +- frontend/src/lib/stores/caches.svelte.ts | 17 ++ frontend/src/lib/types.ts | 1 + frontend/src/routes/+page.svelte | 4 - frontend/src/routes/bots/EmailBotTab.svelte | 4 +- frontend/src/routes/bots/MatrixBotTab.svelte | 4 +- .../command-template-configs/+page.svelte | 5 +- frontend/src/routes/providers/+page.svelte | 18 +- .../providers/WebhookPayloadHistory.svelte | 187 +++++++++--------- .../src/routes/providers/new/+page.svelte | 8 +- frontend/src/routes/settings/+page.svelte | 16 ++ .../src/routes/template-configs/+page.svelte | 5 +- frontend/vite.config.ts | 1 + .../notify_bridge_server/api/app_settings.py | 16 +- .../notify_bridge_server/api/email_bots.py | 10 +- .../notify_bridge_server/api/matrix_bots.py | 9 +- .../src/notify_bridge_server/api/providers.py | 1 + .../src/notify_bridge_server/api/webhooks.py | 45 +++-- .../server/src/notify_bridge_server/config.py | 2 +- .../database/migrations.py | 18 ++ .../notify_bridge_server/database/models.py | 1 + scripts/restart-backend.sh | 11 +- scripts/restart-frontend.sh | 6 +- 29 files changed, 278 insertions(+), 154 deletions(-) diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index f4727dc..ae29bbc 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -58,6 +58,14 @@ export const memorySourceItems = (): GridItem[] => [ { value: 'native', icon: 'mdiMemory', label: t('trackingConfig.memorySourceNative'), desc: t('gridDesc.memorySourceNative') }, ]; +// --- Webhook auth mode --- + +export const webhookAuthModeItems = (): GridItem[] => [ + { value: 'none', icon: 'mdiLockOpen', label: t('providers.authNone'), desc: t('gridDesc.authNone') }, + { value: 'bearer_token', icon: 'mdiKey', label: t('providers.authBearer'), desc: t('gridDesc.authBearer') }, + { value: 'hmac_sha256', icon: 'mdiShieldKey', label: t('providers.authHmac'), desc: t('gridDesc.authHmac') }, +]; + // --- Locale --- export const localeItems = (): GridItem[] => [ diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 2fd4f1f..66a78f6 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -133,6 +133,9 @@ "plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).", "authMode": "Authentication Mode", "authModeHint": "Choose hmac_sha256, bearer_token, or none", + "authNone": "None", + "authBearer": "Bearer Token", + "authHmac": "HMAC-SHA256", "genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.", "maxStoredPayloads": "Max stored payloads", "maxStoredPayloadsHint": "Number of recent payloads to keep for debugging (0 = disabled, max 100)", @@ -658,6 +661,9 @@ "webhookSecretHint": "Secret token to verify webhook requests from Telegram", "cacheTtl": "Media Cache TTL (hours)", "cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", + "locales": "Template Languages", + "supportedLocales": "Supported Locales", + "supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)", "saved": "Settings saved" }, "hints": { @@ -926,6 +932,9 @@ "message_ups_overload": "UPS load exceeded capacity" }, "gridDesc": { + "authNone": "No authentication required", + "authBearer": "Verify requests with a Bearer token", + "authHmac": "Verify payload signature with HMAC-SHA256", "sortNone": "No sorting applied", "sortDate": "Sort by creation date", "sortRating": "Sort by star rating", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 94b6b49..b3a944c 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -133,6 +133,9 @@ "plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).", "authMode": "Режим аутентификации", "authModeHint": "Выберите hmac_sha256, bearer_token или none", + "authNone": "Без аутентификации", + "authBearer": "Bearer Token", + "authHmac": "HMAC-SHA256", "genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.", "maxStoredPayloads": "Макс. сохранённых запросов", "maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)", @@ -658,6 +661,9 @@ "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", "cacheTtl": "TTL кэша медиа (часы)", "cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", + "locales": "Языки шаблонов", + "supportedLocales": "Поддерживаемые локали", + "supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)", "saved": "Настройки сохранены" }, "hints": { @@ -926,6 +932,9 @@ "message_ups_overload": "ИБП перегружен" }, "gridDesc": { + "authNone": "Аутентификация не требуется", + "authBearer": "Проверка запросов по Bearer-токену", + "authHmac": "Проверка подписи через HMAC-SHA256", "sortNone": "Без сортировки", "sortDate": "По дате создания", "sortRating": "По рейтингу", diff --git a/frontend/src/lib/providers/gitea.ts b/frontend/src/lib/providers/gitea.ts index 7ec1d52..5c16645 100644 --- a/frontend/src/lib/providers/gitea.ts +++ b/frontend/src/lib/providers/gitea.ts @@ -56,5 +56,5 @@ export const giteaDescriptor: ProviderDescriptor = { desc: () => '', }, - webhookUrlPattern: '/api/webhooks/gitea/{id}', + webhookUrlPattern: '/api/webhooks/gitea/{token}', }; diff --git a/frontend/src/lib/providers/planka.ts b/frontend/src/lib/providers/planka.ts index a0521d8..d1cb533 100644 --- a/frontend/src/lib/providers/planka.ts +++ b/frontend/src/lib/providers/planka.ts @@ -62,5 +62,5 @@ export const plankaDescriptor: ProviderDescriptor = { desc: () => '', }, - webhookUrlPattern: '/api/webhooks/planka/{id}', + webhookUrlPattern: '/api/webhooks/planka/{token}', }; diff --git a/frontend/src/lib/providers/types.ts b/frontend/src/lib/providers/types.ts index 6bf0b52..599715d 100644 --- a/frontend/src/lib/providers/types.ts +++ b/frontend/src/lib/providers/types.ts @@ -20,7 +20,10 @@ export interface ConfigField { configKey?: string; /** i18n key for the field label. */ label: string; - type: 'text' | 'password' | 'number'; + type: 'text' | 'password' | 'number' | 'grid-select'; + /** Grid-select item source function name from grid-items.ts. */ + gridItems?: string; + gridColumns?: number; placeholder?: string; /** * - `true` → always required diff --git a/frontend/src/lib/providers/webhook.ts b/frontend/src/lib/providers/webhook.ts index 021b16a..fdbc9cc 100644 --- a/frontend/src/lib/providers/webhook.ts +++ b/frontend/src/lib/providers/webhook.ts @@ -10,10 +10,10 @@ export const webhookDescriptor: ProviderDescriptor = { { key: 'auth_mode', configKey: 'auth_mode', label: 'providers.authMode', - type: 'text', - placeholder: 'hmac_sha256 | bearer_token | none', + type: 'grid-select', + gridItems: 'webhookAuthModeItems', + gridColumns: 3, defaultValue: 'none', - hint: 'providers.authModeHint', }, { key: 'webhook_secret', configKey: 'webhook_secret', @@ -57,6 +57,6 @@ export const webhookDescriptor: ProviderDescriptor = { collectionMeta: null, webhookBased: true, - webhookUrlPattern: '/api/webhooks/webhook/{id}', + webhookUrlPattern: '/api/webhooks/webhook/{token}', payloadHistory: true, }; diff --git a/frontend/src/lib/stores/caches.svelte.ts b/frontend/src/lib/stores/caches.svelte.ts index 16270cf..340e4b4 100644 --- a/frontend/src/lib/stores/caches.svelte.ts +++ b/frontend/src/lib/stores/caches.svelte.ts @@ -74,6 +74,23 @@ export const capabilitiesCache = (() => { }; })(); +/** Supported template locales — fetched from app settings. */ +export const supportedLocalesCache = (() => { + let data = $state(['en', 'ru']); + let fetchedAt = $state(0); + const TTL = 300_000; // 5 minutes + return { + get items() { return data; }, + async fetch(force = false): Promise { + if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data; + const { api } = await import('$lib/api'); + data = await api('/settings/locales'); + fetchedAt = Date.now(); + return data; + }, + }; +})(); + /** * All caches keyed by entity type — for search palette and crosslink resolution. */ diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 78cdd5a..c39817d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -6,6 +6,7 @@ export interface ServiceProvider { name: string; icon: string; config: Record; + webhook_token: string; created_at: string; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 7bed5f7..a12b244 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -319,10 +319,6 @@ {/snippet} -
- {@render paginator()} -
- {#if eventsLoading}

{t('dashboard.loadingEvents')}

{:else if status.recent_events.length === 0} diff --git a/frontend/src/routes/bots/EmailBotTab.svelte b/frontend/src/routes/bots/EmailBotTab.svelte index f905c36..b9a8030 100644 --- a/frontend/src/routes/bots/EmailBotTab.svelte +++ b/frontend/src/routes/bots/EmailBotTab.svelte @@ -1,6 +1,6 @@ - -
-
-

{t('webhookLogs.title')}

- {#if logs.length > 0} - {logs.length} +
+ + + {#if expanded} +
+ {#if loading} +
{t('common.loading')}
+ {:else if logs.length === 0} +
{t('webhookLogs.empty')}
+ {:else} +
+ +
+
+ {#each logs as log} + + + {#if expandedId === log.id} +
+ {#if Object.keys(log.headers).length > 0} +
+
{t('webhookLogs.headers')}
+
+ {#each Object.entries(log.headers) as [key, value]} +
{key}: {value}
+ {/each} +
+
+ {/if} + +
+
{t('webhookLogs.body')}
+
{JSON.stringify(log.body, null, 2)}
+
+ + {#if log.status === 'matched' && Object.keys(log.extracted_fields).length > 0} +
+
{t('webhookLogs.extractedFields')}
+
+ {#each Object.entries(log.extracted_fields) as [key, value]} +
{key}: {typeof value === 'object' ? JSON.stringify(value) : String(value)}
+ {/each} +
+
+ {/if} + + {#if log.status === 'error' && log.error_message} +
+
{t('webhookLogs.errorMessage')}
+
{log.error_message}
+
+ {/if} +
+ {/if} + {/each} +
{/if}
- {#if logs.length > 0} - - {/if} -
- - {#if loading} -
{t('common.loading')}
- {:else if logs.length === 0} -
{t('webhookLogs.empty')}
- {:else} -
- {#each logs as log} - - - {#if expandedId === log.id} -
- - {#if Object.keys(log.headers).length > 0} -
-
{t('webhookLogs.headers')}
-
- {#each Object.entries(log.headers) as [key, value]} -
{key}: {value}
- {/each} -
-
- {/if} - - -
-
{t('webhookLogs.body')}
-
{JSON.stringify(log.body, null, 2)}
-
- - - {#if log.status === 'matched' && Object.keys(log.extracted_fields).length > 0} -
-
{t('webhookLogs.extractedFields')}
-
- {#each Object.entries(log.extracted_fields) as [key, value]} -
{key}: {typeof value === 'object' ? JSON.stringify(value) : String(value)}
- {/each} -
-
- {/if} - - - {#if log.status === 'error' && log.error_message} -
-
{t('webhookLogs.errorMessage')}
-
{log.error_message}
-
- {/if} -
- {/if} - {/each} -
{/if} - +
any[]> = { webhookAuthModeItems }; import { getDescriptor, buildProviderFormDefaults } from '$lib/providers'; import Button from '$lib/components/Button.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte'; @@ -100,7 +102,9 @@ {t(field.label)} {#if field.optional}({t('providers.optional')}){/if} - {#if field.type === 'number'} + {#if field.type === 'grid-select' && field.gridItems} + + {:else if field.type === 'number'} {:else} diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 3f4db6e..50dee25 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -18,6 +18,7 @@ external_url: '', telegram_webhook_secret: '', telegram_cache_ttl_hours: '48', + supported_locales: 'en,ru', }); onMount(async () => { @@ -79,6 +80,21 @@
+ + +

+ + {t('settings.locales')} +

+
+
+ + +
+
+
+ diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index c9fa16d..8164392 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -4,7 +4,7 @@ import { api } from '$lib/api'; import { t } from '$lib/i18n'; import { sanitizePreview } from '$lib/sanitize'; - import { templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; + import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -57,7 +57,7 @@ let slotFilter = $state(''); let showPreviewFor = $state>(new Set()); - const LOCALES = ['en', 'ru'] as const; + let LOCALES = $derived(supportedLocalesCache.items); let activeLocale = $state('en'); function toggleSlot(key: string) { @@ -202,6 +202,7 @@ templateConfigsCache.fetch(true), api('/template-configs/variables'), capabilitiesCache.fetch(), + supportedLocalesCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; highlightFromUrl(); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 929eaf7..777997b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { + port: 5175, proxy: { '/api': 'http://localhost:8420', '/docs': 'http://localhost:8420', diff --git a/packages/server/src/notify_bridge_server/api/app_settings.py b/packages/server/src/notify_bridge_server/api/app_settings.py index cf7cfc0..ce5cc9f 100644 --- a/packages/server/src/notify_bridge_server/api/app_settings.py +++ b/packages/server/src/notify_bridge_server/api/app_settings.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -from ..auth.dependencies import require_admin +from ..auth.dependencies import get_current_user, require_admin from ..database.engine import get_session from ..database.models import AppSetting, TelegramBot, User @@ -21,12 +21,14 @@ _SETTING_KEYS = { "external_url": "NOTIFY_BRIDGE_EXTERNAL_URL", "telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET", "telegram_cache_ttl_hours": None, # no env fallback, default 48 + "supported_locales": None, # comma-separated locale codes } _DEFAULTS = { "external_url": "", "telegram_webhook_secret": "", "telegram_cache_ttl_hours": "48", + "supported_locales": "en,ru", } @@ -47,6 +49,7 @@ class SettingsUpdate(BaseModel): external_url: str | None = None telegram_webhook_secret: str | None = None telegram_cache_ttl_hours: str | None = None + supported_locales: str | None = None @router.get("") @@ -105,6 +108,17 @@ async def update_settings( return result +@router.get("/locales") +async def get_supported_locales( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Return list of supported template locales (available to all users).""" + raw = await get_setting(session, "supported_locales") + locales = [loc.strip() for loc in raw.split(",") if loc.strip()] + return locales or ["en"] + + async def _reregister_webhooks( session: AsyncSession, base_url: str, secret: str ) -> None: diff --git a/packages/server/src/notify_bridge_server/api/email_bots.py b/packages/server/src/notify_bridge_server/api/email_bots.py index a2a9443..479aeb4 100644 --- a/packages/server/src/notify_bridge_server/api/email_bots.py +++ b/packages/server/src/notify_bridge_server/api/email_bots.py @@ -2,7 +2,7 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -117,12 +117,16 @@ async def delete_email_bot( @router.post("/{bot_id}/test") async def test_email_bot( bot_id: int, + locale: str = Query("en"), user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Send a test email to the bot's own address to verify SMTP connection.""" bot = await _get_user_bot(session, bot_id, user.id) + from ..services.notifier import _get_test_message + msg = _get_test_message(locale, "email") + from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig client = EmailClient(SmtpConfig( host=bot.smtp_host, @@ -135,8 +139,8 @@ async def test_email_bot( )) result = await client.send( to_email=bot.email, - subject="Notify Bridge — Test Connection", - body_text="This is a test email from Notify Bridge. Your SMTP settings are working correctly.", + subject=f"Notify Bridge — {msg}", + body_text=msg, ) return result diff --git a/packages/server/src/notify_bridge_server/api/matrix_bots.py b/packages/server/src/notify_bridge_server/api/matrix_bots.py index b1dd953..644a6a7 100644 --- a/packages/server/src/notify_bridge_server/api/matrix_bots.py +++ b/packages/server/src/notify_bridge_server/api/matrix_bots.py @@ -2,7 +2,7 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -99,6 +99,7 @@ async def delete_matrix_bot( async def test_matrix_bot( bot_id: int, room_id: str = "", + locale: str = Query("en"), user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): @@ -127,12 +128,14 @@ async def test_matrix_bot( # Optionally send a test message if room_id: + from ..services.notifier import _get_test_message from notify_bridge_core.notifications.matrix.client import MatrixClient + msg = _get_test_message(locale, "matrix") client = MatrixClient(http, bot.homeserver_url, bot.access_token) send_result = await client.send_message( room_id, - "Test message from Notify Bridge", - html_message="Test message from Notify Bridge", + msg, + html_message=f"{msg}", ) result["send_result"] = send_result diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py index 4ba6a20..400b8a5 100644 --- a/packages/server/src/notify_bridge_server/api/providers.py +++ b/packages/server/src/notify_bridge_server/api/providers.py @@ -447,6 +447,7 @@ def _provider_response(p: ServiceProvider) -> dict: "name": p.name, "icon": p.icon, "config": config, + "webhook_token": p.webhook_token, "created_at": p.created_at.isoformat(), } diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py index c120009..b5c85ff 100644 --- a/packages/server/src/notify_bridge_server/api/webhooks.py +++ b/packages/server/src/notify_bridge_server/api/webhooks.py @@ -34,6 +34,22 @@ _LOGGER = logging.getLogger(__name__) router = APIRouter(prefix="/api/webhooks", tags=["webhooks"]) +async def _get_provider_by_token( + session: AsyncSession, token: str, expected_type: str, +) -> ServiceProvider: + """Look up a provider by its webhook_token and expected type.""" + result = await session.exec( + select(ServiceProvider).where( + ServiceProvider.webhook_token == token, + ServiceProvider.type == expected_type, + ) + ) + provider = result.first() + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + return provider + + # --------------------------------------------------------------------------- # HMAC-SHA256 validation # --------------------------------------------------------------------------- @@ -168,16 +184,14 @@ async def _dispatch_webhook_event( # Gitea webhook endpoint # --------------------------------------------------------------------------- -@router.post("/gitea/{provider_id}") -async def gitea_webhook(provider_id: int, request: Request): +@router.post("/gitea/{token}") +async def gitea_webhook(token: str, 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") + provider = await _get_provider_by_token(session, token, "gitea") webhook_secret = (provider.config or {}).get("webhook_secret", "") @@ -211,7 +225,7 @@ async def gitea_webhook(provider_id: int, request: Request): # --- Dispatch --- dispatched = await _dispatch_webhook_event( engine=engine, - provider_id=provider_id, + provider_id=provider.id, provider_name=provider.name, provider_config=provider.config or {}, event=event, @@ -239,16 +253,14 @@ def _verify_planka_token(expected_token: str, request: Request) -> bool: return False -@router.post("/planka/{provider_id}") -async def planka_webhook(provider_id: int, request: Request): +@router.post("/planka/{token}") +async def planka_webhook(token: str, request: Request): """Receive a Planka webhook, parse it, filter, and dispatch notifications.""" engine = get_engine() # --- Load provider and validate token --- async with AsyncSession(engine) as session: - provider = await session.get(ServiceProvider, provider_id) - if not provider or provider.type != "planka": - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_provider_by_token(session, token, "planka") webhook_secret = (provider.config or {}).get("webhook_secret", "") @@ -279,7 +291,7 @@ async def planka_webhook(provider_id: int, request: Request): # --- Dispatch --- dispatched = await _dispatch_webhook_event( engine=engine, - provider_id=provider_id, + provider_id=provider.id, provider_name=provider.name, provider_config=provider.config or {}, event=event, @@ -394,17 +406,16 @@ async def _save_webhook_log( _LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True) -@router.post("/webhook/{provider_id}") -async def generic_webhook(provider_id: int, request: Request): +@router.post("/webhook/{token}") +async def generic_webhook(token: str, request: Request): """Receive a generic webhook, extract variables via JSONPath, and dispatch notifications.""" engine = get_engine() # --- Load provider and validate auth --- async with AsyncSession(engine) as session: - provider = await session.get(ServiceProvider, provider_id) - if not provider or provider.type != "webhook": - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_provider_by_token(session, token, "webhook") + provider_id = provider.id provider_config = provider.config or {} provider_name = provider.name diff --git a/packages/server/src/notify_bridge_server/config.py b/packages/server/src/notify_bridge_server/config.py index 3073b52..67c737c 100644 --- a/packages/server/src/notify_bridge_server/config.py +++ b/packages/server/src/notify_bridge_server/config.py @@ -33,7 +33,7 @@ class Settings(BaseSettings): telegram_webhook_secret: str = "" - cors_allowed_origins: str = "http://localhost:5173" + cors_allowed_origins: str = "http://localhost:5175" """Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com').""" static_dir: str = "" diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index e86adbd..4fffe3b 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -272,6 +272,24 @@ async def migrate_schema(engine: AsyncEngine) -> None: ) logger.info("Added commands_enabled column to telegram_chat table") + # Add webhook_token to service_provider if missing + if await _has_table(conn, "service_provider"): + if not await _has_column(conn, "service_provider", "webhook_token"): + await conn.execute( + text("ALTER TABLE service_provider ADD COLUMN webhook_token TEXT DEFAULT ''") + ) + logger.info("Added webhook_token column to service_provider table") + # Backfill existing providers with unique tokens + import uuid + providers = (await conn.execute(text("SELECT id FROM service_provider"))).fetchall() + for row in providers: + await conn.execute( + text("UPDATE service_provider SET webhook_token = :tok WHERE id = :pid"), + {"tok": uuid.uuid4().hex, "pid": row[0]}, + ) + if providers: + logger.info("Backfilled webhook_token for %d existing providers", len(providers)) + # --------------------------------------------------------------------------- # Legacy tracker_target migration (pre-Phase 1) diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index 228d922..2f87cde 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -33,6 +33,7 @@ class ServiceProvider(SQLModel, table=True): name: str icon: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + webhook_token: str = Field(default_factory=lambda: uuid4().hex) created_at: datetime = Field(default_factory=_utcnow) diff --git a/scripts/restart-backend.sh b/scripts/restart-backend.sh index 9b9ea84..ed6a4d6 100644 --- a/scripts/restart-backend.sh +++ b/scripts/restart-backend.sh @@ -11,14 +11,15 @@ if [ -n "$PID" ]; then sleep 1 fi -# Resolve python — py launcher needs absolute path for nohup on Windows -if command -v python3 &>/dev/null; then +# Resolve python — prefer py launcher on Windows (python3 may be the Store stub) +if command -v py &>/dev/null; then + PYTHON=$(py -3 -c "import sys; print(sys.executable)" 2>/dev/null) +elif command -v python3 &>/dev/null && python3 --version &>/dev/null; then PYTHON=python3 -elif command -v python &>/dev/null; then +elif command -v python &>/dev/null && python --version &>/dev/null; then PYTHON=python else - PYTHON=$(py -3.13 -c "import sys; print(sys.executable)" 2>/dev/null \ - || py -3 -c "import sys; print(sys.executable)") + echo "Python not found"; exit 1 fi # Start backend diff --git a/scripts/restart-frontend.sh b/scripts/restart-frontend.sh index 016c5d1..a453e75 100644 --- a/scripts/restart-frontend.sh +++ b/scripts/restart-frontend.sh @@ -6,7 +6,7 @@ set -e cd "$(dirname "$0")/.." # Kill existing frontend -PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) +PID=$(netstat -ano 2>/dev/null | grep ':5175.*LISTENING' | awk '{print $5}' | head -1) if [ -n "$PID" ]; then taskkill //F //PID "$PID" 2>/dev/null || true sleep 1 @@ -14,8 +14,8 @@ fi # Start frontend cd frontend -npx vite dev --port 5173 --host > /dev/null 2>&1 & +npx vite dev --port 5175 --host > /dev/null 2>&1 & cd .. sleep 4 -curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5173/ +curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5175/