feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish
- 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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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="<b>Test message</b> from Notify Bridge",
|
||||
msg,
|
||||
html_message=f"<b>{msg}</b>",
|
||||
)
|
||||
result["send_result"] = send_result
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user