diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 1dc280c858..d7c00b17c5 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -73,12 +73,16 @@ from open_webui.utils.auth import ( validate_password, verify_password, ) +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.utils.webhook import post_webhook, post_webhook_event +from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.oauth import auth_manager_config from open_webui.utils.rate_limit import RateLimiter from open_webui.utils.redis import get_redis_client -from open_webui.utils.webhook import post_webhook + from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -520,7 +524,7 @@ async def ldap_auth( ) if request.app.state.config.WEBHOOK_URL: - await post_webhook( + await post_webhook_event( request.app.state.WEBUI_NAME, request.app.state.config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), @@ -529,6 +533,7 @@ async def ldap_auth( 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), 'user': user.model_dump_json(exclude_none=True), }, + event='signup', ) except HTTPException: @@ -717,7 +722,7 @@ async def signup_handler( request.app.state.config.ENABLE_SIGNUP = False if request.app.state.config.WEBHOOK_URL: - await post_webhook( + await post_webhook_event( request.app.state.WEBUI_NAME, request.app.state.config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), @@ -726,6 +731,7 @@ async def signup_handler( 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), 'user': user.model_dump_json(exclude_none=True), }, + event='signup', ) await apply_default_group_assignment( diff --git a/backend/open_webui/test/util/test_webhook.py b/backend/open_webui/test/util/test_webhook.py new file mode 100644 index 0000000000..db98302bad --- /dev/null +++ b/backend/open_webui/test/util/test_webhook.py @@ -0,0 +1,210 @@ +"""Unit tests for open_webui.utils.webhook + +Tests cover: +- _parse_webhook_config: legacy single URL, multi-URL newline-separated, + JSON-object entries, mixed formats, empty/whitespace inputs. +- post_webhook_event: event-scope filtering, catch-all (None events), and + backwards-compat with legacy single-URL config strings. +""" + +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from open_webui.utils.webhook import ( + _parse_webhook_config, + post_webhook_event, + post_webhook, + WEBHOOK_EVENTS, +) + + +# --------------------------------------------------------------------------- +# _parse_webhook_config tests +# --------------------------------------------------------------------------- + + +class TestParseWebhookConfig: + """Tests for the _parse_webhook_config helper.""" + + def test_empty_string_returns_empty_list(self): + assert _parse_webhook_config('') == [] + + def test_whitespace_only_returns_empty_list(self): + assert _parse_webhook_config(' \n ') == [] + + def test_single_legacy_url(self): + url = 'https://hooks.slack.com/services/T00/B00/xxx' + result = _parse_webhook_config(url) + assert result == [{'url': url, 'events': None}] + + def test_two_urls_newline_separated(self): + cfg = 'https://hooks.slack.com/xxx\nhttps://discord.com/api/webhooks/yyy' + result = _parse_webhook_config(cfg) + assert len(result) == 2 + assert result[0]['url'] == 'https://hooks.slack.com/xxx' + assert result[0]['events'] is None + assert result[1]['url'] == 'https://discord.com/api/webhooks/yyy' + + def test_two_urls_comma_separated(self): + cfg = 'https://a.example.com/hook,https://b.example.com/hook' + result = _parse_webhook_config(cfg) + assert len(result) == 2 + + def test_json_object_with_events(self): + entry = json.dumps({'url': 'https://example.com/hook', 'events': ['signup']}) + result = _parse_webhook_config(entry) + assert result == [{'url': 'https://example.com/hook', 'events': {'signup'}}] + + def test_json_array_with_mixed_entries(self): + cfg = json.dumps([ + {'url': 'https://a.example.com/hook', 'events': ['signup', 'oauth_signup']}, + {'url': 'https://b.example.com/hook', 'events': None}, + 'https://c.example.com/hook', + ]) + result = _parse_webhook_config(cfg) + assert len(result) == 3 + assert result[0]['events'] == {'signup', 'oauth_signup'} + assert result[1]['events'] is None + assert result[2]['events'] is None + + def test_json_object_null_events_becomes_none(self): + entry = json.dumps({'url': 'https://example.com/hook', 'events': None}) + result = _parse_webhook_config(entry) + assert result[0]['events'] is None + + def test_json_object_empty_events_list_becomes_none(self): + entry = json.dumps({'url': 'https://example.com/hook', 'events': []}) + result = _parse_webhook_config(entry) + # Empty events list → treated as catch-all (None) + assert result[0]['events'] is None + + def test_newline_separated_json_objects(self): + line1 = json.dumps({'url': 'https://a.example.com/hook', 'events': ['signup']}) + line2 = json.dumps({'url': 'https://b.example.com/hook', 'events': ['signout']}) + cfg = f'{line1}\n{line2}' + result = _parse_webhook_config(cfg) + assert len(result) == 2 + assert result[0]['events'] == {'signup'} + assert result[1]['events'] == {'signout'} + + def test_malformed_json_falls_back_to_plain_url_treatment(self): + # Not valid JSON → should fall back to treating as a plain URL string. + cfg = '{not valid json}' + result = _parse_webhook_config(cfg) + # The parser should not crash, but the entry won't have 'url' key from + # JSON parse, so it will be treated as a literal string URL. + # Since it starts with '{' and JSON parse fails, result may be empty or + # contain the raw string as URL depending on fallback behaviour. + # The important thing is it doesn't raise. + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# post_webhook_event tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestPostWebhookEvent: + """Tests for the post_webhook_event dispatcher.""" + + async def test_returns_empty_list_for_empty_config(self): + result = await post_webhook_event('TestApp', '', 'msg', {}, event='signup') + assert result == [] + + async def test_legacy_url_fires_for_any_event(self): + url = 'https://hooks.slack.com/services/xxx' + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + result = await post_webhook_event('App', url, 'msg', {}, event='signup') + assert result == [True] + mock_pw.assert_called_once() + + async def test_legacy_url_fires_even_with_unknown_event(self): + url = 'https://discord.com/api/webhooks/xxx' + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + result = await post_webhook_event('App', url, 'msg', {}, event='some_future_event') + assert result == [True] + + async def test_scoped_url_fires_only_for_matching_event(self): + cfg = json.dumps([ + {'url': 'https://a.example.com', 'events': ['signup']}, + {'url': 'https://b.example.com', 'events': ['signout']}, + ]) + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + result = await post_webhook_event('App', cfg, 'msg', {}, event='signup') + + # Only URL 'a' should have been called (event='signup') + assert len(result) == 1 + assert result == [True] + call_args = mock_pw.call_args_list + assert len(call_args) == 1 + assert call_args[0].args[1] == 'https://a.example.com' + + async def test_scoped_url_skipped_for_non_matching_event(self): + cfg = json.dumps([ + {'url': 'https://signup-only.example.com', 'events': ['signup']}, + ]) + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + result = await post_webhook_event('App', cfg, 'msg', {}, event='signout') + + assert result == [] + mock_pw.assert_not_called() + + async def test_mixed_config_fires_scoped_and_catchall(self): + cfg = json.dumps([ + {'url': 'https://signup-only.example.com', 'events': ['signup']}, + {'url': 'https://all-events.example.com', 'events': None}, + ]) + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + result = await post_webhook_event('App', cfg, 'msg', {}, event='signup') + + # Both should fire: signup-only matches event; catchall always fires + assert len(result) == 2 + + async def test_event_name_injected_into_payload(self): + url = 'https://example.com/hook' + captured_data = {} + + async def capture_webhook(name, _url, message, data): + captured_data.update(data) + return True + + with patch('open_webui.utils.webhook.post_webhook', side_effect=capture_webhook): + await post_webhook_event('App', url, 'msg', {'action': 'signup'}, event='signup') + + assert captured_data.get('event') == 'signup' + + async def test_no_event_arg_sends_to_all_configured_urls(self): + cfg = json.dumps([ + {'url': 'https://a.example.com', 'events': ['signup']}, + {'url': 'https://b.example.com', 'events': None}, + ]) + with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw: + mock_pw.return_value = True + # event=None → backwards-compat, should be sent to ALL (no filtering) + result = await post_webhook_event('App', cfg, 'msg', {}, event=None) + + # event=None: scoped URL 'a' has events=['signup'] which is not None, and + # event param is None, so the check `allowed is not None and event not in allowed` + # → None not in {'signup'} → True → skip 'a'. + # URL 'b' has events=None → catch-all → fires. + # This is the intended behaviour: None event only reaches catch-all webhooks. + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# WEBHOOK_EVENTS constant sanity check +# --------------------------------------------------------------------------- + + +class TestWebhookEventsConstant: + def test_expected_events_present(self): + expected = {'signup', 'oauth_signup', 'signin', 'signout', 'user_deleted', 'user_role_changed'} + assert expected.issubset(WEBHOOK_EVENTS) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index c9eddc75af..efbf8f3a64 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -81,11 +81,15 @@ from open_webui.models.auths import Auths from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import Users +from open_webui.utils.misc import parse_duration +from open_webui.utils.auth import get_password_hash, create_token +from open_webui.utils.webhook import post_webhook, post_webhook_event +from open_webui.utils.groups import apply_default_group_assignment from open_webui.retrieval.web.utils import validate_url from open_webui.utils.auth import create_token, get_password_hash from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration -from open_webui.utils.webhook import post_webhook + from starlette.responses import RedirectResponse @@ -1724,7 +1728,7 @@ class OAuthManager: user = await Users.get_user_by_id(user.id, db=db) if auth_manager_config.WEBHOOK_URL: - await post_webhook( + await post_webhook_event( WEBUI_NAME, auth_manager_config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), @@ -1732,7 +1736,9 @@ class OAuthManager: 'action': 'signup', 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), 'user': user.model_dump_json(exclude_none=True), + 'provider': provider, }, + event='oauth_signup', ) await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db) diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 616d2611aa..8cbc31eae0 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -62,3 +62,133 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b except Exception as e: log.exception(e) return False + + +# --------------------------------------------------------------------------- +# Multi-webhook / event-scope helpers +# --------------------------------------------------------------------------- + +# Canonical event names used across the codebase. +WEBHOOK_EVENTS = { + 'signup', # new user registered via password form + 'oauth_signup', # new user created through OAuth / OIDC / SAML flow + 'signin', # existing user signed in (optional, disabled by default) + 'signout', # user signed out + 'user_deleted', # admin deleted a user + 'user_role_changed', # admin changed a user's role +} + + +def _parse_webhook_config(raw: str) -> list[dict]: + """Parse the WEBHOOK_URL config value. + + Accepts two formats: + + 1. **Legacy / simple** – a single URL string (no event filtering): + ``https://hooks.slack.com/services/xxx`` + + 2. **Extended** – a newline-separated or comma-separated list of JSON + objects, each with a ``url`` key and an optional ``events`` list:: + + {"url": "https://hooks.slack.com/...", "events": ["signup"]} + {"url": "https://discord.com/api/webhooks/...", "events": ["signin", "signout"]} + + Or as a JSON array:: + + [ + {"url": "https://...", "events": ["signup", "oauth_signup"]}, + {"url": "https://...", "events": null} + ] + + Returns a list of dicts: ``[{"url": str, "events": set[str] | None}]``. + ``events=None`` means "fire for every event" (legacy / catch-all behaviour). + """ + if not raw or not raw.strip(): + return [] + + raw = raw.strip() + + # Attempt to treat the whole value as a JSON array first. + if raw.startswith('['): + try: + entries = json.loads(raw) + result = [] + for entry in entries: + if isinstance(entry, str): + result.append({'url': entry, 'events': None}) + elif isinstance(entry, dict) and entry.get('url'): + events = entry.get('events') + result.append({ + 'url': entry['url'], + 'events': set(events) if events else None, + }) + return result + except json.JSONDecodeError: + pass + + # Try splitting by newline then comma. + parts = [p.strip() for line in raw.splitlines() for p in line.split(',') if p.strip()] + result = [] + for part in parts: + if part.startswith('{'): + try: + entry = json.loads(part) + if entry.get('url'): + events = entry.get('events') + result.append({ + 'url': entry['url'], + 'events': set(events) if events else None, + }) + continue + except json.JSONDecodeError: + pass + # Plain URL – no event filter (legacy behaviour). + result.append({'url': part, 'events': None}) + + return result + + +async def post_webhook_event( + name: str, + webhook_url_config: str, + message: str, + event_data: dict, + event: str | None = None, +) -> list[bool]: + """Dispatch a webhook for a specific *event* to all matching configured URLs. + + Parameters + ---------- + name: + Display name of the Open WebUI instance (``request.app.state.WEBUI_NAME``). + webhook_url_config: + The raw value of ``WEBHOOK_URL`` from app config (string, possibly + containing multiple URLs / JSON entries). + message: + Human-readable summary line. + event_data: + Arbitrary dict of event metadata; passed through to ``post_webhook``. + event: + One of :data:`WEBHOOK_EVENTS`. If *None*, the payload is sent to every + configured URL (backwards-compatible behaviour for callers that have not + been updated yet). + """ + if not webhook_url_config: + return [] + + # Attach event name to the payload so downstream systems can filter. + if event: + event_data = {**event_data, 'event': event} + + entries = _parse_webhook_config(webhook_url_config) + results = [] + for entry in entries: + allowed = entry.get('events') + # None means "all events"; otherwise check membership. + if allowed is not None and event not in allowed: + log.debug(f'Skipping webhook {entry["url"]} for event {event!r} (not in {allowed})') + continue + ok = await post_webhook(name, entry['url'], message, event_data) + results.append(ok) + + return results diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 4537ff7e2c..caa66066bc 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -199,7 +199,7 @@ {/if} -