diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 7cd46bd..3bbc7a4 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -133,6 +133,8 @@ "authMode": "Authentication Mode", "authModeHint": "Choose hmac_sha256, bearer_token, or none", "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)", "webhookSecretRequired": "Webhook secret is required", "apiToken": "API Token", "apiTokenHint": "Optional. Needed for connection testing and repository listing.", @@ -152,11 +154,29 @@ "gpRefreshTokenKeep": "Refresh Token (leave empty to keep current)", "gpRefreshTokenHint": "Obtain from Google OAuth Playground (developers.google.com/oauthplayground) with the Photos Library API scope.", "gpAllFieldsRequired": "Client ID, Client Secret, and Refresh Token are all required", + "storePayloads": "Store incoming payloads", + "storePayloadsHint": "Save recent webhook request bodies for debugging", + "maxStoredPayloads": "Max stored payloads", + "maxStoredPayloadsHint": "Number of recent payloads to keep (1-100)", "testAndSave": "Test & Save", "saveWithoutTest": "Save without testing", "selectType": "Select a provider type", "testFailed": "Connection test failed" }, + "webhookLogs": { + "title": "Recent Payloads", + "empty": "No payloads recorded yet", + "clear": "Clear history", + "confirmClear": "Clear all stored payloads for this provider?", + "statusMatched": "Matched", + "statusUnmatched": "Unmatched", + "statusError": "Error", + "headers": "Headers", + "body": "Request Body", + "extractedFields": "Extracted Fields", + "errorMessage": "Error", + "cleared": "Payload history cleared" + }, "notificationTracker": { "title": "Notification Trackers", "description": "Monitor albums for changes", @@ -951,6 +971,20 @@ "providerGooglePhotos": "Google Photos albums & shared libraries", "providerWebhook": "Receive events via HTTP POST" }, + "webhookLogs": { + "title": "Recent Payloads", + "empty": "No payloads recorded yet", + "clear": "Clear history", + "confirmClear": "Clear all stored payloads for this provider?", + "statusMatched": "Matched", + "statusUnmatched": "Unmatched", + "statusError": "Error", + "headers": "Headers", + "body": "Request Body", + "extractedFields": "Extracted Fields", + "errorMessage": "Error", + "cleared": "Payload history cleared" + }, "error": { "notFound": "Page not found", "goHome": "Go home" diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index c2ea3c6..e4de4c3 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -133,6 +133,8 @@ "authMode": "Режим аутентификации", "authModeHint": "Выберите hmac_sha256, bearer_token или none", "genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.", + "maxStoredPayloads": "Макс. сохранённых запросов", + "maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)", "webhookSecretRequired": "Секрет вебхука обязателен", "apiToken": "API токен", "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", @@ -152,11 +154,29 @@ "gpRefreshTokenKeep": "Refresh Token (оставьте пустым для сохранения текущего)", "gpRefreshTokenHint": "Получите через Google OAuth Playground (developers.google.com/oauthplayground) с областью Photos Library API.", "gpAllFieldsRequired": "Client ID, Client Secret и Refresh Token обязательны", + "storePayloads": "Сохранять входящие данные", + "storePayloadsHint": "Сохранять тела недавних вебхук-запросов для отладки", + "maxStoredPayloads": "Макс. сохранённых запросов", + "maxStoredPayloadsHint": "Количество сохраняемых запросов (1-100)", "testAndSave": "Проверить и сохранить", "saveWithoutTest": "Сохранить без проверки", "selectType": "Выберите тип провайдера", "testFailed": "Ошибка проверки подключения" }, + "webhookLogs": { + "title": "Последние запросы", + "empty": "Записей пока нет", + "clear": "Очистить историю", + "confirmClear": "Очистить все сохранённые запросы для этого провайдера?", + "statusMatched": "Совпадение", + "statusUnmatched": "Не совпало", + "statusError": "Ошибка", + "headers": "Заголовки", + "body": "Тело запроса", + "extractedFields": "Извлечённые поля", + "errorMessage": "Ошибка", + "cleared": "История запросов очищена" + }, "notificationTracker": { "title": "Трекеры уведомлений", "description": "Отслеживание изменений в альбомах", @@ -951,6 +971,20 @@ "providerGooglePhotos": "Альбомы и общие библиотеки Google Фото", "providerWebhook": "Приём событий через HTTP POST" }, + "webhookLogs": { + "title": "Последние запросы", + "empty": "Записей пока нет", + "clear": "Очистить историю", + "confirmClear": "Очистить все сохранённые запросы для этого провайдера?", + "statusMatched": "Совпадение", + "statusUnmatched": "Не совпало", + "statusError": "Ошибка", + "headers": "Заголовки", + "body": "Тело запроса", + "extractedFields": "Извлечённые поля", + "errorMessage": "Ошибка", + "cleared": "История запросов очищена" + }, "error": { "notFound": "Страница не найдена", "goHome": "На главную" diff --git a/frontend/src/lib/providers/types.ts b/frontend/src/lib/providers/types.ts index 01408b1..6bf0b52 100644 --- a/frontend/src/lib/providers/types.ts +++ b/frontend/src/lib/providers/types.ts @@ -139,6 +139,8 @@ export interface ProviderDescriptor { // ── Webhook URL display ── /** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */ webhookUrlPattern?: string; + /** Whether this provider stores incoming payload history for debugging. */ + payloadHistory?: boolean; // ── Provider-specific hooks ── /** diff --git a/frontend/src/lib/providers/webhook.ts b/frontend/src/lib/providers/webhook.ts index 91b76a7..021b16a 100644 --- a/frontend/src/lib/providers/webhook.ts +++ b/frontend/src/lib/providers/webhook.ts @@ -21,12 +21,22 @@ export const webhookDescriptor: ProviderDescriptor = { type: 'password', optional: true, hint: 'providers.genericWebhookSecretHint', }, + { + key: 'max_stored_payloads', configKey: 'max_stored_payloads', + label: 'providers.maxStoredPayloads', + type: 'number', + min: 0, max: 100, + defaultValue: 20, + hint: 'providers.maxStoredPayloadsHint', + }, ], buildConfig(form, editing) { const config: Record = { auth_mode: form.auth_mode || 'none', payload_mappings: form.payload_mappings || [], + store_payloads: form.store_payloads !== '0' && form.store_payloads !== 0, + max_stored_payloads: Math.max(1, Math.min(100, Number(form.max_stored_payloads) || 20)), }; if (form.webhook_secret) config.webhook_secret = form.webhook_secret; if (form.event_type_path) config.event_type_path = form.event_type_path; @@ -36,7 +46,9 @@ export const webhookDescriptor: ProviderDescriptor = { hasConfigChanged(form, existing) { return form.auth_mode !== (existing.auth_mode || 'none') || - !!form.webhook_secret; + !!form.webhook_secret || + (form.store_payloads !== '0' && form.store_payloads !== 0) !== (existing.store_payloads !== false) || + Number(form.max_stored_payloads || 20) !== Number(existing.max_stored_payloads || 20); }, eventFields: [ @@ -46,4 +58,5 @@ export const webhookDescriptor: ProviderDescriptor = { collectionMeta: null, webhookBased: true, webhookUrlPattern: '/api/webhooks/webhook/{id}', + payloadHistory: true, }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f2a3740..3b82763 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -181,6 +181,18 @@ export interface EventLog { created_at: string; } +export interface WebhookPayloadLog { + id: number; + provider_id: number; + method: string; + headers: Record; + body: Record; + status: 'matched' | 'unmatched' | 'error'; + extracted_fields: Record; + error_message: string; + created_at: string; +} + export interface User { id: number; username: string; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e711529..c4cf5a8 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -59,19 +59,21 @@ let openSearch: (() => void) | undefined; let pwdCurrent = $state(''); let pwdNew = $state(''); + let pwdConfirm = $state(''); let pwdMsg = $state(''); let pwdSuccess = $state(false); async function changePassword(e: SubmitEvent) { e.preventDefault(); pwdMsg = ''; pwdSuccess = false; if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; } + if (pwdNew !== pwdConfirm) { pwdMsg = t('auth.passwordMismatch'); return; } try { await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) }); pwdMsg = t('common.changePassword'); pwdSuccess = true; - pwdCurrent = ''; pwdNew = ''; + pwdCurrent = ''; pwdNew = ''; pwdConfirm = ''; snackSuccess(t('snack.passwordChanged')); - setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000); + setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000); } catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); } } @@ -605,7 +607,7 @@ {/if} - { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}> + { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
@@ -614,7 +616,12 @@
- +
+
+ +
{#if pwdMsg} diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 211e629..89fd3e6 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -20,6 +20,7 @@ import { highlightFromUrl } from '$lib/highlight'; import { getDescriptor, buildProviderFormDefaults } from '$lib/providers'; import Button from '$lib/components/Button.svelte'; + import WebhookPayloadHistory from './WebhookPayloadHistory.svelte'; import type { ServiceProvider } from '$lib/types'; let allProviders = $derived(providersCache.items); @@ -263,6 +264,9 @@ + {#if provDesc?.payloadHistory && !showForm} + + {/if} {/each} {/if} diff --git a/frontend/src/routes/providers/WebhookPayloadHistory.svelte b/frontend/src/routes/providers/WebhookPayloadHistory.svelte new file mode 100644 index 0000000..d787948 --- /dev/null +++ b/frontend/src/routes/providers/WebhookPayloadHistory.svelte @@ -0,0 +1,165 @@ + + + +
+
+

{t('webhookLogs.title')}

+ {#if logs.length > 0} + {logs.length} + {/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} +
+ + showClearConfirm = false} +/> diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py index 6a47e6e..4ba6a20 100644 --- a/packages/server/src/notify_bridge_server/api/providers.py +++ b/packages/server/src/notify_bridge_server/api/providers.py @@ -99,6 +99,8 @@ class WebhookProviderConfig(BaseModel): payload_mappings: list[PayloadMapping] = [] event_type_path: str | None = None collection_path: str | None = None + store_payloads: bool = True + max_stored_payloads: int = 20 # 1-100 _PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = { diff --git a/packages/server/src/notify_bridge_server/api/webhook_logs.py b/packages/server/src/notify_bridge_server/api/webhook_logs.py new file mode 100644 index 0000000..2f4a5ee --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/webhook_logs.py @@ -0,0 +1,76 @@ +"""Webhook payload log API routes.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete as sa_delete +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import ServiceProvider, User, WebhookPayloadLog +from .helpers import get_owned_entity + +_LOGGER = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/providers", tags=["webhook-logs"]) + + +@router.get("/{provider_id}/webhook-logs") +async def list_webhook_logs( + provider_id: int, + limit: int = 20, + offset: int = 0, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """List recent webhook payload logs for a provider.""" + provider = await get_owned_entity( + session, ServiceProvider, provider_id, user.id, + not_found_msg="Provider not found", + ) + if provider.type != "webhook": + raise HTTPException(status_code=400, detail="Not a webhook provider") + + result = await session.exec( + select(WebhookPayloadLog) + .where(WebhookPayloadLog.provider_id == provider_id) + .order_by(WebhookPayloadLog.created_at.desc()) + .offset(offset) + .limit(min(limit, 100)) + ) + return [ + { + "id": log.id, + "provider_id": log.provider_id, + "method": log.method, + "headers": log.headers, + "body": log.body, + "status": log.status, + "extracted_fields": log.extracted_fields, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in result.all() + ] + + +@router.delete("/{provider_id}/webhook-logs", status_code=204) +async def clear_webhook_logs( + provider_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """Clear all webhook payload logs for a provider.""" + await get_owned_entity( + session, ServiceProvider, provider_id, user.id, + not_found_msg="Provider not found", + ) + await session.execute( + sa_delete(WebhookPayloadLog) + .where(WebhookPayloadLog.provider_id == provider_id) + ) + await session.commit() diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py index 7bbd15e..23c3266 100644 --- a/packages/server/src/notify_bridge_server/api/webhooks.py +++ b/packages/server/src/notify_bridge_server/api/webhooks.py @@ -18,10 +18,13 @@ from notify_bridge_core.providers.planka.event_parser import parse_webhook as pa from notify_bridge_core.providers.webhook.event_parser import parse_webhook as parse_generic_webhook from ..database.engine import get_engine +from sqlalchemy import delete as sa_delete, func + from ..database.models import ( EventLog, NotificationTracker, ServiceProvider, + WebhookPayloadLog, ) from ..services.dispatch_helpers import event_allowed_by_config, load_link_data @@ -334,6 +337,62 @@ def _verify_generic_webhook_auth( return False +def _filter_headers(raw_headers: dict[str, str]) -> dict[str, str]: + """Keep only safe headers for logging (no Authorization).""" + safe: dict[str, str] = {} + for k, v in raw_headers.items(): + kl = k.lower() + if kl in ("content-type", "user-agent") or kl.startswith("x-"): + safe[k] = v + return safe + + +async def _save_webhook_log( + session: AsyncSession, + provider_id: int, + method: str, + headers: dict[str, str], + body: dict[str, Any] | str, + status: str, + extracted_fields: dict[str, Any] | None = None, + error_message: str = "", + max_count: int = 20, +) -> None: + """Insert a webhook payload log entry and prune old ones.""" + try: + body_json = body if isinstance(body, dict) else {} + session.add(WebhookPayloadLog( + provider_id=provider_id, + method=method, + headers=headers, + body=body_json, + status=status, + extracted_fields=extracted_fields or {}, + error_message=error_message, + )) + await session.flush() + count_result = await session.exec( + select(func.count(WebhookPayloadLog.id)) + .where(WebhookPayloadLog.provider_id == provider_id) + ) + total = count_result.one() + if total > max_count: + oldest = await session.exec( + select(WebhookPayloadLog.id) + .where(WebhookPayloadLog.provider_id == provider_id) + .order_by(WebhookPayloadLog.created_at.asc()) + .limit(total - max_count) + ) + ids_to_delete = list(oldest.all()) + if ids_to_delete: + await session.execute( + sa_delete(WebhookPayloadLog) + .where(WebhookPayloadLog.id.in_(ids_to_delete)) + ) + except Exception: + _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): """Receive a generic webhook, extract variables via JSONPath, and dispatch notifications.""" @@ -348,6 +407,9 @@ async def generic_webhook(provider_id: int, request: Request): provider_config = provider.config or {} provider_name = provider.name + store_payloads = provider_config.get("store_payloads", True) + max_stored = min(max(int(provider_config.get("max_stored_payloads", 20)), 1), 100) + raw_body = await request.body() # Enforce payload size limit BEFORE parsing JSON @@ -357,16 +419,32 @@ async def generic_webhook(provider_id: int, request: Request): if not _verify_generic_webhook_auth(provider_config, request, raw_body): raise HTTPException(status_code=403, detail="Authentication failed") + safe_headers = _filter_headers(dict(request.headers)) + # Parse JSON payload try: payload = await request.json() except Exception: + if store_payloads: + async with AsyncSession(get_engine()) as log_session: + await _save_webhook_log( + log_session, provider_id, request.method, safe_headers, + {}, "error", error_message="Invalid JSON", max_count=max_stored, + ) + await log_session.commit() raise HTTPException(status_code=400, detail="Invalid JSON") # Parse via JSONPath mappings req_headers = dict(request.headers) event = parse_generic_webhook(payload, provider_name, provider_config, headers=req_headers) if event is None: + if store_payloads: + async with AsyncSession(get_engine()) as log_session: + await _save_webhook_log( + log_session, provider_id, request.method, safe_headers, + payload, "unmatched", max_count=max_stored, + ) + await log_session.commit() return {"ok": True, "skipped": "parse failed"} # Inject source IP @@ -427,6 +505,15 @@ async def generic_webhook(provider_id: int, request: Request): tracker.id, r.get("error", "unknown"), ) + # Log matched payload + if store_payloads: + await _save_webhook_log( + session, provider_id, request.method, safe_headers, + payload, "matched" if dispatched > 0 else "unmatched", + extracted_fields=dict(event.extra), + max_count=max_stored, + ) + await session.commit() return {"ok": True, "dispatched": dispatched} diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index 767f8d0..228d922 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -545,6 +545,22 @@ class ActionExecution(SQLModel, table=True): trigger: str = Field(default="scheduled") # "scheduled", "manual", "dry_run" +class WebhookPayloadLog(SQLModel, table=True): + """Log of incoming webhook payloads for debugging and replay.""" + + __tablename__ = "webhook_payload_log" + + id: int | None = Field(default=None, primary_key=True) + provider_id: int = Field(foreign_key="service_provider.id", index=True) + method: str = Field(default="POST") + headers: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + body: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + status: str = Field(default="matched") # "matched" | "unmatched" | "error" + extracted_fields: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + error_message: str = Field(default="") + created_at: datetime = Field(default_factory=_utcnow) + + class AppSetting(SQLModel, table=True): """Key-value app-level settings (admin-configurable).""" diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index c1c4d43..8304e42 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -43,6 +43,7 @@ from .api.action_rules import router as action_rules_router from .api.action_types import router as action_types_router from .commands.webhook import router as webhook_router, set_webhook_secret from .api.webhooks import router as webhooks_router +from .api.webhook_logs import router as webhook_logs_router @asynccontextmanager @@ -141,6 +142,7 @@ app.include_router(command_trackers_router) app.include_router(command_template_configs_router) app.include_router(webhook_router) app.include_router(webhooks_router) +app.include_router(webhook_logs_router) @app.get("/api/health")