diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index 7fc9304bce..863d8be4a0 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -411,6 +411,9 @@ async def lifespan(app: FastAPI):
await install_tool_and_function_dependencies()
app.state.redis = get_redis_client(async_mode=True)
+ from open_webui.socket.apps import connections as app_connections
+
+ await app_connections.start(app.state.redis)
if app.state.redis is not None:
app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app))
@@ -503,6 +506,7 @@ async def lifespan(app: FastAPI):
yield
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_STARTED, source='system')
+ await app_connections.close()
# Shutdown: clean up shared resources
from open_webui.utils.session_pool import close_session
diff --git a/backend/open_webui/models/apps.py b/backend/open_webui/models/apps.py
index 4329fe5cc9..8dd8581a62 100644
--- a/backend/open_webui/models/apps.py
+++ b/backend/open_webui/models/apps.py
@@ -159,6 +159,7 @@ class AppConnectionForm(StrictModel):
class AppResponse(BaseModel):
+ connection_status: Literal['connecting', 'connected', 'disconnected'] = 'disconnected'
id: str
slug: str
name: str
@@ -217,6 +218,7 @@ class AppsTable:
return None
app.is_active = False
app.api_key = None
+ app.data = {k: v for k, v in app.data.items() if k not in ('connection_token', 'installation_id', 'webui_url')}
app.updated_at = max(time.time_ns(), app.updated_at + 1)
await db.execute(
update(ChannelMember).where(ChannelMember.app_id == id).values(is_active=False, left_at=time.time_ns())
diff --git a/backend/open_webui/routers/apps.py b/backend/open_webui/routers/apps.py
index c735e36995..b82356a485 100644
--- a/backend/open_webui/routers/apps.py
+++ b/backend/open_webui/routers/apps.py
@@ -2,51 +2,37 @@
import time
import uuid
-from urllib.parse import urlsplit
-import jwt
-from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
+from fastapi import APIRouter, Depends, Header, HTTPException, status
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrant, AccessGrants
from open_webui.models.apps import (
App,
AppAccessResponse,
- AppChannelMessage,
AppConnectionForm,
AppForm,
AppInteractionForm,
AppManifestForm,
AppResponse,
Apps,
- AppTokenForm,
AppUpdateForm,
Manifest,
)
-from open_webui.models.channels import Channel, ChannelMember, Channels
from open_webui.models.config import Config
from open_webui.models.messages import Message
-from open_webui.models.users import Users
from open_webui.utils.apps import (
- check_app_channel_membership,
check_app_invocation_access,
- create_app_message,
decode_app_context_token,
generate_app_channel_reply,
get_app_by_id,
get_app_encryption_cipher,
- get_app_message_data,
get_app_response,
- get_verified_app,
has_app_access,
- require_scope,
- send_app_event,
send_app_request,
- update_app_message,
validate_app_url,
- verify_app_token,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
-from sqlalchemy import and_, or_, select, update
+from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -63,8 +49,10 @@ async def get_apps(user=Depends(get_verified_user), db: AsyncSession = Depends(g
if not await Config.get('apps.enable'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled')
apps = (await db.scalars(select(App).order_by(App.name))).all()
+ from open_webui.socket.apps import connections
+
return [
- get_app_response(app)
+ {**get_app_response(app), 'connection_status': await connections.status(app)}
for app in apps
if user.role == 'admin' or (app.is_active and await has_app_access(app, user, db=db))
]
@@ -91,9 +79,7 @@ async def preview_app_manifest(
if not api_key:
raise HTTPException(400, 'An app API key is required')
try:
- status_code, raw = await send_app_request(
- form_data.url, headers={'Authorization': f'Bearer {api_key}'}
- )
+ status_code, raw = await send_app_request(form_data.url, headers={'Authorization': f'Bearer {api_key}'})
if status_code in (401, 403):
raise HTTPException(400, 'App rejected the API key. Check the API key configured on the app server.')
if status_code != 200:
@@ -155,161 +141,6 @@ async def create_new_app(
return get_app_response(app)
-# Fixed runtime routes must precede /{app_id}.
-############################
-# GetCurrentApp
-############################
-
-
-@router.get('/runtime/me', response_model=AppResponse)
-async def get_current_app(app=Depends(get_verified_app)):
- return get_app_response(app)
-
-
-############################
-# GetAppChannels
-############################
-
-
-@router.get('/runtime/channels')
-async def get_app_channels(app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)):
- if not await Config.get('channels.enable'):
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Channels are disabled')
- await get_app_by_id(app.id, require_active=True, db=db)
- require_scope(app, 'channels:read')
- channels = (
- await db.scalars(
- select(Channel)
- .join(ChannelMember, Channel.id == ChannelMember.channel_id)
- .where(
- ChannelMember.app_id == app.id,
- ChannelMember.is_active.is_(True),
- Channel.deleted_at.is_(None),
- Channel.archived_at.is_(None),
- )
- )
- ).all()
- result = []
- for channel in channels:
- channel_data = await Channels.get_channel_by_id(channel.id, db=db)
- is_private = (
- channel_data.is_private is True
- if channel_data.type == 'group'
- else not any(grant.principal_id == '*' for grant in channel_data.access_grants)
- )
- result.append({'id': channel.id, 'name': channel.name, 'type': channel.type, 'is_private': is_private})
- return result
-
-
-############################
-# AuthorizeAppExecution
-############################
-
-
-@router.post('/runtime/authorize')
-async def authorize_app_execution(
- form_data: AppTokenForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
-):
- value = decode_app_context_token(app, 'execution', form_data.token)
- user = await Users.get_user_by_id(value['actor_id'], db=db)
- if not user:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Actor is unavailable')
- await check_app_invocation_access(app, value['channel_id'], user, db=db)
- require_scope(app, value['scope'])
- return {
- 'authorized': True,
- 'operation_id': value['operation_id'],
- 'actor': {'id': user.id, 'is_admin': user.role == 'admin'},
- 'channel_id': value['channel_id'],
- 'type': value.get('type'),
- 'data': value.get('data'),
- }
-
-
-############################
-# VerifyAppEvent
-############################
-
-
-@router.post('/runtime/events/verify')
-async def verify_app_event(
- form_data: AppTokenForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
-):
- value = decode_app_context_token(app, 'event', form_data.token)
- await check_app_channel_membership(app, value['channel']['id'], db=db)
- require_scope(app, 'channels:messages:read')
- return {k: v for k, v in value.items() if k not in ('purpose', 'exp', 'nonce')}
-
-
-############################
-# GetAppChannelMessages
-############################
-
-
-@router.get('/runtime/channels/{channel_id}/messages')
-async def get_app_channel_messages(
- channel_id: str,
- before: str | None = None,
- parent_id: str | None = None,
- limit: int = Query(default=50, ge=1, le=100),
- app=Depends(get_verified_app),
- db: AsyncSession = Depends(get_async_session),
-):
- await check_app_channel_membership(app, channel_id, db=db)
- require_scope(app, 'channels:messages:read')
- query = select(Message).where(Message.channel_id == channel_id, Message.parent_id == parent_id)
- if parent_id:
- parent = await db.get(Message, parent_id)
- if not parent or parent.channel_id != channel_id:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Thread not found')
- if before:
- cursor = await db.get(Message, before)
- if not cursor or cursor.channel_id != channel_id or cursor.parent_id != parent_id:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid message cursor')
- query = query.where(
- or_(
- Message.created_at < cursor.created_at,
- and_(Message.created_at == cursor.created_at, Message.id < cursor.id),
- )
- )
- rows = (await db.scalars(query.order_by(Message.created_at.desc(), Message.id.desc()).limit(limit + 1))).all()
- return {
- 'messages': [get_app_message_data(row) for row in rows[:limit]],
- 'next_cursor': rows[limit - 1].id if len(rows) > limit else None,
- }
-
-
-############################
-# PostAppMessage
-############################
-
-
-@router.post('/runtime/channels/{channel_id}/messages', status_code=status.HTTP_201_CREATED)
-async def post_app_message(
- channel_id: str,
- form_data: AppChannelMessage,
- app=Depends(get_verified_app),
- db: AsyncSession = Depends(get_async_session),
-):
- return await create_app_message(db, app, channel_id, form_data)
-
-
-############################
-# UpdateAppMessageById
-############################
-
-
-@router.patch('/runtime/messages/{message_id}')
-async def update_app_message_by_id(
- message_id: str,
- form_data: AppChannelMessage,
- if_match: str | None = Header(default=None),
- app=Depends(get_verified_app),
- db: AsyncSession = Depends(get_async_session),
-):
- return await update_app_message(db, app, message_id, form_data, if_match)
-
-
############################
# GetAppDetailsById
############################
@@ -319,7 +150,10 @@ async def update_app_message_by_id(
async def get_app_details_by_id(
app_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
- result = get_app_response(await get_app_by_id(app_id, db=db))
+ from open_webui.socket.apps import connections
+
+ app = await get_app_by_id(app_id, db=db)
+ result = {**get_app_response(app), 'connection_status': await connections.status(app)}
result['access_grants'] = [
{'principal_type': g.principal_type, 'principal_id': g.principal_id, 'permission': g.permission}
for g in (
@@ -362,6 +196,9 @@ async def update_app_by_id(
'manifest': form_data.manifest.model_dump(),
'granted_scopes': form_data.granted_scopes,
}
+ if form_data.manifest.public_key != app.data['manifest']['public_key']:
+ data['data'].pop('connection_token', None)
+ data['data'].pop('installation_id', None)
data['is_active'] = False
elif form_data.granted_scopes is not None:
if not set(form_data.granted_scopes) <= set(app.data['manifest']['requested_scopes']):
@@ -380,90 +217,33 @@ async def update_app_by_id(
async def connect_app_by_id(
app_id: str,
form_data: AppConnectionForm,
- request: Request,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
app = await get_app_by_id(app_id, db=db)
- webui_url = str(await Config.get('webui.url') or request.base_url).strip().rstrip('/')
- try:
- origin = urlsplit(webui_url)
- if (
- origin.scheme not in ('http', 'https')
- or not origin.hostname
- or origin.username is not None
- or origin.password is not None
- or origin.path
- or origin.query
- or origin.fragment
- or '\\' in webui_url
- or len(webui_url) > 2048
- or any(c.isspace() for c in webui_url)
- ):
- raise ValueError('Invalid WebUI origin')
- host = f'[{origin.hostname}]' if ':' in origin.hostname else origin.hostname.lower()
- port = origin.port or (443 if origin.scheme == 'https' else 80)
- suffix = f':{port}' if port != (443 if origin.scheme == 'https' else 80) else ''
- webui_url = f'{origin.scheme}://{host}{suffix}'
- except ValueError:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail='Invalid WebUI URL. Set WebUI URL in Settings > Admin > General to an HTTP(S) origin '
- 'without a path, query, fragment or credentials.',
- ) from None
+ from open_webui.socket.apps import connections
+
if form_data.api_key is not None:
- app.api_key = get_app_encryption_cipher().encrypt(form_data.api_key.get_secret_value().encode()).decode()
+ cipher = get_app_encryption_cipher()
+ value = form_data.api_key.get_secret_value()
+ old = cipher.decrypt(app.api_key.encode()).decode() if app.api_key else None
+ if value != old:
+ app.data = {k: v for k, v in app.data.items() if k not in ('connection_token', 'installation_id')}
+ app.api_key = cipher.encrypt(value.encode()).decode()
if not app.api_key:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='An app API key is required')
- app.data = {**app.data, 'webui_url': webui_url}
- app.is_active = False
- app.updated_at = max(time.time_ns(), app.updated_at + 1)
- checked_at, api_key = app.updated_at, app.api_key
+ raise HTTPException(400, 'An app API key is required')
app = await Apps.update_app_by_id(
- app_id, {'api_key': api_key, 'data': app.data, 'is_active': False, 'updated_at': checked_at}, db=db
+ app_id,
+ {
+ 'api_key': app.api_key,
+ 'data': app.data,
+ 'is_active': False,
+ 'updated_at': max(time.time_ns(), app.updated_at + 1),
+ },
+ db=db,
)
- await db.commit()
- challenge = str(uuid.uuid4())
- status_code, value = await send_app_event(
- app,
- {'id': str(uuid.uuid4()), 'type': 'connect', 'data': {'challenge': challenge}},
- timeout=20,
- )
- if status_code != 200:
- detail = {
- 401: 'App rejected the API key. Enter the API key configured on the app server.',
- 403: 'App refused the connection. Check the app server access rules and logs.',
- 400: 'App rejected the connection request. Check the WebUI origin and app server configuration.',
- 404: 'App event endpoint was not found. Check the URL in app settings.',
- 405: 'App endpoint does not accept POST requests. Use its event endpoint URL.',
- 503: 'App is busy or shutting down. Connect again when it is ready.',
- 504: 'App connection request timed out. Check the app server logs.',
- }.get(status_code, f'App returned HTTP {status_code}. Check the app server logs.')
- raise HTTPException(504 if status_code == 504 else 502, detail)
- if not isinstance(value, dict) or not isinstance(value.get('proof'), str):
- raise HTTPException(502, 'App did not return an identity proof. Check its event endpoint and SDK version.')
- try:
- claims = verify_app_token(app, value['proof'], 'webui-app-connect+jwt')
- if claims.get('challenge') != challenge:
- raise ValueError()
- except jwt.InvalidSignatureError:
- raise HTTPException(
- 502, 'App public key does not match the installed key. Review the app public key in settings.'
- ) from None
- except (jwt.InvalidTokenError, ValueError, TypeError, KeyError):
- raise HTTPException(
- 502, 'App identity proof is invalid or expired. Check the installed public key and server clocks.'
- ) from None
- result = await db.execute(
- update(App)
- .where(App.id == app_id, App.updated_at == checked_at, App.api_key == api_key)
- .values(is_active=True, updated_at=max(time.time_ns(), checked_at + 1))
- )
- if result.rowcount != 1:
- await db.rollback()
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='App changed while connecting; connect again')
- await db.commit()
- return get_app_response(await get_app_by_id(app_id, db=db))
+ app = await connections.connect(app)
+ return {**get_app_response(app), 'connection_status': 'connected'}
############################
diff --git a/backend/open_webui/socket/apps.py b/backend/open_webui/socket/apps.py
new file mode 100644
index 0000000000..be3b308c12
--- /dev/null
+++ b/backend/open_webui/socket/apps.py
@@ -0,0 +1,446 @@
+"""Outbound app sockets. Each WebUI worker owns its own authenticated connections."""
+
+import asyncio
+import hashlib
+import json
+import logging
+import random
+import re
+import time
+import uuid
+from urllib.parse import urlsplit
+
+import aiohttp
+import jwt
+import socketio
+from fastapi import HTTPException
+from open_webui.env import REDIS_KEY_PREFIX
+from open_webui.internal.db import get_async_db
+from open_webui.models.apps import App, AppChannelMessage, Apps, AppTokenForm
+from open_webui.models.config import Config
+from open_webui.utils.apps import (
+ AppTCPConnector,
+ get_app_by_id,
+ get_app_encryption_cipher,
+ get_app_response,
+ validate_app_url,
+)
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+from socketio.packet import Packet
+from sqlalchemy import select, update
+
+log = logging.getLogger(__name__)
+NAMESPACE = '/apps'
+MAX_PACKET = 1048576
+
+
+class JSON:
+ @staticmethod
+ def dumps(value, **kwargs):
+ return json.dumps(value, **{**kwargs, 'ensure_ascii': False, 'separators': (',', ':')})
+
+ loads = staticmethod(json.loads)
+
+
+class JSONPacket(Packet):
+ def decode(self, encoded_packet):
+ if not isinstance(encoded_packet, str) or len(encoded_packet.encode()) > MAX_PACKET:
+ raise ValueError('Socket packet exceeds 1 MiB or is not JSON')
+ if encoded_packet[:1] in ('5', '6'):
+ raise ValueError('Binary app packets are not supported')
+ return super().decode(encoded_packet)
+
+
+class AppHistoryRequest(BaseModel):
+ model_config = ConfigDict(extra='forbid')
+ channel_id: str
+ parent_id: str | None = None
+ before: str | None = None
+ limit: int = Field(default=50, ge=1, le=100)
+
+
+def connection_config(app):
+ return (app.api_key, app.data.get('connection_token'), app.data['manifest']['public_key'], app.data['url'])
+
+
+class AppConnection:
+ def __init__(self, app):
+ self.app = app
+ self.session = None
+ self.sio = None
+ self.ready = False
+ self.verified = False
+ self.requests = set()
+ self.connected = asyncio.Event()
+
+ async def open(self, *, enroll=False):
+ allow_private = validate_app_url(self.app.data['url'])
+ trace = aiohttp.TraceConfig()
+
+ async def reject_redirect(*args):
+ raise ValueError('App endpoint must not redirect')
+
+ trace.on_request_redirect.append(reject_redirect)
+ self.session = aiohttp.ClientSession(
+ connector=AppTCPConnector(allow_private=allow_private),
+ trust_env=False,
+ trace_configs=[trace],
+ timeout=aiohttp.ClientTimeout(total=20),
+ )
+ self.sio = socketio.AsyncClient(
+ http_session=self.session,
+ reconnection=False,
+ json=JSON,
+ serializer=JSONPacket,
+ websocket_extra_options={'max_msg_size': MAX_PACKET},
+ )
+ self.sio.on('app:request', self.request, namespace=NAMESPACE)
+
+ async def disconnected(*args):
+ self.ready = False
+ self.connected.clear()
+
+ self.sio.on('disconnect', disconnected, namespace=NAMESPACE)
+ cipher = get_app_encryption_cipher()
+ key = cipher.decrypt(self.app.api_key.encode()).decode()
+ saved = self.app.data.get('connection_token')
+ if not saved and not enroll:
+ raise HTTPException(409, 'Reconnect this app to upgrade its transport')
+ token = cipher.decrypt(saved.encode()).decode() if saved else None
+ challenge = str(uuid.uuid4())
+ url = urlsplit(self.app.data['url'])
+ await self.sio.connect(
+ f'{url.scheme}://{url.netloc}',
+ socketio_path=url.path.rstrip('/') + '/socket.io',
+ headers={'Authorization': 'Bearer ' + key},
+ namespaces=[NAMESPACE],
+ transports=['websocket'],
+ wait_timeout=20,
+ )
+ result = await self.sio.call(
+ 'app:connect',
+ {
+ 'app_id': self.app.id,
+ 'challenge': challenge,
+ 'connection_token': token,
+ },
+ namespace=NAMESPACE,
+ timeout=10,
+ )
+ if not isinstance(result, dict) or result.get('status') != 200:
+ raise HTTPException(403, 'App rejected its connection credential. Review credentials before reconnecting.')
+ token, installation_id = self.verify_connection(result['data'], challenge, saved)
+ self.verified = True
+ if enroll:
+ async with get_async_db() as db:
+ values = {k: v for k, v in self.app.data.items() if k != 'webui_url'}
+ values.update(installation_id=installation_id, connection_token=cipher.encrypt(token.encode()).decode())
+ result = await db.execute(
+ update(App)
+ .where(
+ App.id == self.app.id,
+ App.updated_at == self.app.updated_at,
+ )
+ .values(data=values, is_active=False, updated_at=max(time.time_ns(), self.app.updated_at + 1))
+ )
+ if result.rowcount != 1:
+ raise HTTPException(409, 'App changed while connecting; connect again')
+ await db.commit()
+ self.app = await Apps.get_app_by_id(self.app.id, db=db)
+ result = await self.sio.call('app:ready', {}, namespace=NAMESPACE, timeout=10)
+ if result != {'status': 200, 'data': {'received': True}}:
+ raise HTTPException(502, 'App could not complete its bidirectional connection check')
+ if enroll:
+ async with get_async_db() as db:
+ result = await db.execute(
+ update(App)
+ .where(
+ App.id == self.app.id,
+ App.updated_at == self.app.updated_at,
+ )
+ .values(is_active=True, updated_at=max(time.time_ns(), self.app.updated_at + 1))
+ )
+ if result.rowcount != 1:
+ raise HTTPException(409, 'App changed while connecting; connect again')
+ await db.commit()
+ self.app = await Apps.get_app_by_id(self.app.id, db=db)
+ self.ready = True
+ self.connected.set()
+
+ def verify_connection(self, data, challenge, saved):
+ token = data['connection_token']
+ if not isinstance(token, str) or len(token) > 8192:
+ raise ValueError('Invalid app connection token')
+ proof = data['proof']
+ if jwt.get_unverified_header(proof).get('typ') != 'oix-connect+jwt':
+ raise ValueError('Invalid app identity proof')
+ claims = jwt.decode(
+ proof,
+ self.app.data['manifest']['public_key'],
+ algorithms=['EdDSA'],
+ audience='oix:connect',
+ options={'require': ['sub', 'app_id', 'manifest_id', 'iat', 'exp', 'challenge', 'token_hash']},
+ )
+ installation_id = str(uuid.UUID(data['installation_id']))
+ if (
+ claims['sub'] != installation_id
+ or claims['app_id'] != self.app.id
+ or claims['manifest_id'] != self.app.data['manifest']['id']
+ or claims['challenge'] != challenge
+ or claims['token_hash'] != hashlib.sha256(token.encode()).hexdigest()
+ or type(claims['iat']) is not int
+ or type(claims['exp']) is not int
+ or not 0 < claims['exp'] - claims['iat'] <= 60
+ ):
+ raise ValueError('Invalid app identity proof')
+ if saved and installation_id != self.app.data.get('installation_id'):
+ raise ValueError('App installation identity changed')
+ return token, installation_id
+
+ async def request(self, packet):
+ task = asyncio.current_task()
+ self.requests.add(task)
+ try:
+ if not isinstance(packet, dict) or len(JSON.dumps(packet).encode()) > MAX_PACKET - 128:
+ raise HTTPException(413, 'App request exceeds 1 MiB')
+ operation = packet.get('operation')
+ arguments = packet.get('arguments', {})
+ if not self.verified or (not self.ready and operation != 'ping'):
+ raise HTTPException(503 if self.verified else 403, 'App connection is not ready')
+ if operation == 'ping' and arguments == {}:
+ return {'status': 200, 'data': {'received': True}}
+ async with asyncio.timeout(10):
+ async with get_async_db() as db:
+ app = await get_app_by_id(self.app.id, db=db, require_active=True)
+ if connection_config(app) != connection_config(self.app):
+ raise HTTPException(403, 'App credentials changed')
+ value = await execute_request(app, operation, arguments, db)
+ result = {'status': 200, 'data': value}
+ if len(JSON.dumps(result).encode()) > MAX_PACKET - 128:
+ raise HTTPException(413, 'App response exceeds 1 MiB')
+ return result
+ except HTTPException as exc:
+ return {'status': exc.status_code, 'error': exc.detail}
+ except ValidationError:
+ return {'status': 400, 'error': 'Invalid app operation arguments'}
+ except TimeoutError:
+ return {'status': 504, 'error': 'App operation timed out; outcome may be unknown'}
+ except Exception:
+ log.exception('App operation failed for %s', self.app.id)
+ return {'status': 500, 'error': 'App operation failed'}
+ finally:
+ self.requests.discard(task)
+
+ async def close(self):
+ self.ready = False
+ self.connected.clear()
+ if self.sio:
+ await self.sio.disconnect()
+ if self.session:
+ await self.session.close()
+
+
+async def execute_request(app, operation, arguments, db):
+ from open_webui.utils import apps as runtime
+ from open_webui.utils.apps import create_app_message, update_app_message
+
+ if not isinstance(arguments, dict):
+ raise HTTPException(400, 'Invalid app operation arguments')
+ if operation in ('get_me', 'list_channels') and not arguments:
+ if operation == 'get_me':
+ return get_app_response(app)
+ return await runtime.get_app_channels(app=app, db=db)
+ if operation in ('authorize', 'verify_event'):
+ form = AppTokenForm.model_validate(arguments)
+ handler = runtime.authorize_app_execution if operation == 'authorize' else runtime.verify_app_event
+ return await handler(form_data=form, app=app, db=db)
+ if operation == 'list_messages':
+ form = AppHistoryRequest.model_validate(arguments)
+ return await runtime.get_app_channel_messages(**form.model_dump(), app=app, db=db)
+ if operation in ('create_message', 'update_message'):
+ arguments = dict(arguments)
+ identifier = arguments.pop('channel_id' if operation == 'create_message' else 'message_id', None)
+ if not isinstance(identifier, str) or not 1 <= len(identifier) <= 100:
+ raise HTTPException(400, 'Invalid resource ID')
+ etag = arguments.pop('etag', None) if operation == 'update_message' else None
+ form = AppChannelMessage.model_validate(arguments)
+ if operation == 'create_message':
+ return await create_app_message(db, app, identifier, form)
+ if not isinstance(etag, str) or not re.fullmatch(r'[0-9]+', etag):
+ raise HTTPException(400, 'If-Match is required')
+ return await update_app_message(db, app, identifier, form, f'"{etag}"')
+ raise HTTPException(400, 'Unknown app operation')
+
+
+class AppConnections:
+ def __init__(self):
+ self.peers = {}
+ self.tasks = {}
+ self.locks = {}
+ self.worker_id = str(uuid.uuid4())
+ self.redis = None
+ self.loop = None
+ self.closing = False
+
+ async def start(self, redis=None):
+ self.redis = redis
+ self.closing = False
+ self.loop = asyncio.create_task(self.reconcile())
+
+ async def maintain(self, app):
+ delay = 1
+ while not self.closing:
+ peer = AppConnection(app)
+ self.peers[app.id] = peer
+ try:
+ async with asyncio.timeout(20):
+ await peer.open()
+ delay = 1
+ await peer.sio.wait()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ log.warning('App %s socket unavailable: %s', app.id, type(exc).__name__)
+ finally:
+ await peer.close()
+ await asyncio.sleep(random.uniform(delay / 2, delay))
+ delay = min(delay * 2, 30)
+
+ async def reconcile(self):
+ while not self.closing:
+ try:
+ async with get_async_db() as db:
+ apps = (
+ list((await db.scalars(select(App).where(App.is_active.is_(True)))).all())
+ if await Config.get('apps.enable')
+ else []
+ )
+ snapshots = [await Apps.get_app_by_id(app.id, db=db) for app in apps]
+ active = {app.id: app for app in snapshots if app.data.get('connection_token')}
+ for app_id in set(self.tasks) | set(active):
+ await self.refresh(app_id)
+ if self.redis:
+ for app_id, peer in list(self.peers.items()):
+ key = f'{REDIS_KEY_PREFIX}:apps:connections:{app_id}'
+ if peer.ready:
+ await self.redis.zadd(key, {self.worker_id: time.time() + 15})
+ await self.redis.expire(key, 30)
+ else:
+ await self.redis.zrem(key, self.worker_id)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ log.warning('App connection reconciliation failed')
+ await asyncio.sleep(5)
+
+ async def refresh(self, app_id):
+ async with self.locks.setdefault(app_id, asyncio.Lock()):
+ # Refresh after taking the lock: an admin may have just connected this app.
+ app = await Apps.get_app_by_id(app_id)
+ enabled = await Config.get('apps.enable')
+ peer = self.peers.get(app_id)
+ if not enabled or not app or not app.is_active or not app.data.get('connection_token'):
+ await self.remove(app_id)
+ else:
+ if peer and connection_config(app) != connection_config(peer.app):
+ await self.remove(app_id)
+ if app_id not in self.tasks:
+ self.tasks[app_id] = asyncio.create_task(self.maintain(app))
+
+ async def remove(self, app_id):
+ task = self.tasks.pop(app_id, None)
+ if task:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ peer = self.peers.pop(app_id, None)
+ if peer:
+ await peer.close()
+ if self.redis:
+ try:
+ await self.redis.zrem(f'{REDIS_KEY_PREFIX}:apps:connections:{app_id}', self.worker_id)
+ except Exception:
+ pass # Liveness expires after a crashed worker or Redis outage.
+
+ async def connect(self, app):
+ async with self.locks.setdefault(app.id, asyncio.Lock()):
+ await self.remove(app.id)
+ peer = AppConnection(app)
+ try:
+ async with asyncio.timeout(20):
+ await peer.open(enroll=True)
+ except HTTPException:
+ await peer.close()
+ raise
+ except Exception as exc:
+ await peer.close()
+ log.warning('App %s connection failed: %s', app.id, type(exc).__name__)
+ raise HTTPException(
+ 502, 'App connection failed. Check the API key, pinned public key and Socket.IO endpoint.'
+ ) from None
+ self.peers[app.id] = peer
+
+ async def resume():
+ try:
+ await peer.sio.wait()
+ finally:
+ await peer.close()
+ await self.maintain(peer.app)
+
+ self.tasks[app.id] = asyncio.create_task(resume())
+ return peer.app
+
+ async def send(self, app, payload, timeout):
+ if self.closing:
+ raise HTTPException(503, 'App connections are shutting down')
+ async with self.locks.setdefault(app.id, asyncio.Lock()):
+ peer = self.peers.get(app.id)
+ if peer is None or connection_config(peer.app) != connection_config(app):
+ await self.remove(app.id)
+ if not app.data.get('connection_token'):
+ raise HTTPException(409, 'Reconnect this app to upgrade its transport')
+ self.tasks[app.id] = asyncio.create_task(self.maintain(app))
+ await asyncio.sleep(0)
+ peer = self.peers[app.id]
+ if not peer.ready:
+ raise HTTPException(503, 'App is disconnected; event was not sent')
+ try:
+ result = await peer.sio.call('app:event', payload, namespace=NAMESPACE, timeout=timeout)
+ except Exception:
+ raise HTTPException(504, 'App connection lost or timed out; execution outcome may be unknown') from None
+ if not isinstance(result, dict) or len(JSON.dumps(result).encode()) > 262144 + 128:
+ raise HTTPException(502, 'App returned an invalid or oversized response')
+ status = result.get('status')
+ if type(status) is not int:
+ raise HTTPException(502, 'App returned an invalid status')
+ return status, result.get('data')
+
+ async def status(self, app):
+ if not app.is_active:
+ return 'disconnected'
+ if self.redis:
+ try:
+ if await self.redis.zcount(f'{REDIS_KEY_PREFIX}:apps:connections:{app.id}', time.time(), '+inf'):
+ return 'connected'
+ except Exception:
+ return 'disconnected'
+ peer = self.peers.get(app.id)
+ return 'connected' if peer and peer.ready else 'connecting' if peer else 'disconnected'
+
+ async def close(self):
+ self.closing = True
+ if self.loop:
+ self.loop.cancel()
+ await asyncio.gather(self.loop, return_exceptions=True)
+ for peer in self.peers.values():
+ peer.ready = False
+ pending = set().union(*(peer.requests for peer in self.peers.values())) if self.peers else set()
+ if pending:
+ _, unfinished = await asyncio.wait(pending, timeout=30)
+ for task in unfinished:
+ task.cancel()
+ await asyncio.gather(*unfinished, return_exceptions=True)
+ for app_id in list(self.peers):
+ await self.remove(app_id)
+
+
+connections = AppConnections()
diff --git a/backend/open_webui/utils/apps.py b/backend/open_webui/utils/apps.py
index 4d4cad3cda..09ca6c5eb5 100644
--- a/backend/open_webui/utils/apps.py
+++ b/backend/open_webui/utils/apps.py
@@ -14,20 +14,20 @@ from typing import Any
from urllib.parse import urlsplit
import aiohttp
-import jwt
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from fastapi import Depends, Header, HTTPException, status
+from fastapi import HTTPException, status
from open_webui.env import WEBUI_SECRET_KEY
-from open_webui.internal.db import get_async_db, get_async_session
+from open_webui.internal.db import get_async_db
from open_webui.models.access_grants import AccessGrants
-from open_webui.models.apps import AppChannelMessage, AppChannelReply, AppModel, Apps
-from open_webui.models.channels import ChannelModel, Channels
+from open_webui.models.apps import AppChannelMessage, AppChannelReply, AppModel, Apps, AppTokenForm
+from open_webui.models.channels import Channel, ChannelMember, ChannelModel, Channels
from open_webui.models.config import Config
-from open_webui.models.messages import MessageForm, Messages
+from open_webui.models.messages import Message, MessageForm, Messages
from open_webui.models.users import UserModel, Users
from open_webui.tasks import create_task
+from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -50,6 +50,8 @@ def get_app_url_origin(url: str) -> str:
def validate_app_url(url: str) -> bool:
"""Validate the endpoint and return whether its private origin is explicitly allowed."""
origin = get_app_url_origin(url)
+ if urlsplit(url).query:
+ raise ValueError('App URL must not contain a query')
private_origins = {
get_app_url_origin(x.strip()) for x in os.getenv('APP_ALLOWED_PRIVATE_ORIGINS', '').split(',') if x.strip()
}
@@ -133,8 +135,13 @@ async def send_app_request(
# Only local, fixed diagnostics may be shown; never echo arbitrary app responses.
details = {
'Invalid service URL': 'Invalid app URL. Use an HTTP(S) URL without credentials or a fragment.',
- 'App endpoints require HTTPS': 'App endpoints require HTTPS. For local HTTP, configure APP_ALLOWED_PRIVATE_ORIGINS.',
- 'App endpoint resolves to a prohibited address': 'App resolves to a private or reserved address. For local development, configure APP_ALLOWED_PRIVATE_ORIGINS.',
+ 'App endpoints require HTTPS': (
+ 'App endpoints require HTTPS. For local HTTP, configure APP_ALLOWED_PRIVATE_ORIGINS.'
+ ),
+ 'App endpoint resolves to a prohibited address': (
+ 'App resolves to a private or reserved address. '
+ 'For local development, configure APP_ALLOWED_PRIVATE_ORIGINS.'
+ ),
'App response is too large': 'App response exceeds the allowed size.',
}
raise HTTPException(502, details.get(str(exc), 'App returned an invalid response. Expected JSON.')) from None
@@ -203,39 +210,14 @@ def decode_app_context_token(app: AppModel, purpose: str, token: str) -> dict:
raise HTTPException(403, 'Invalid or expired app context') from None
-def verify_app_token(app: AppModel, token: str, token_type: str = 'webui-app+jwt') -> dict:
- header = jwt.get_unverified_header(token)
- if header.get('typ') != token_type or any(k in header for k in ('jku', 'jwk', 'x5u', 'crit')):
- raise ValueError('Invalid app token type or key source')
- claims = jwt.decode(
- token,
- app.data['manifest']['public_key'],
- algorithms=['EdDSA'],
- audience=app.data['webui_url'] + '/api/v1/apps/runtime',
- options={'require': ['sub', 'aud', 'iat', 'exp'], 'strict_aud': True},
- )
- if (
- claims['sub'] != app.id
- or type(claims['iat']) is not int
- or type(claims['exp']) is not int
- or not 0 < claims['exp'] - claims['iat'] <= 60
- ):
- raise ValueError('Invalid app identity or lifetime')
- return claims
-
-
async def send_app_event(app: AppModel, event_data: dict, *, timeout: float = 3) -> tuple[int, Any]:
- body = _serialize_json({**event_data, 'app_id': app.id, 'webui_url': app.data['webui_url']})
- if len(body) > 1048576:
+ from open_webui.socket.apps import connections
+
+ payload = {**event_data, 'app_id': app.id, 'installation_id': app.data.get('installation_id')}
+ if len(_serialize_json(payload)) > 1048576 - 128:
log.warning('App %s event exceeds the 1 MiB delivery limit', app.id)
raise ValueError('App event exceeds 1 MiB')
- api_key = get_app_encryption_cipher().decrypt(app.api_key.encode()).decode()
- return await send_app_request(
- app.data['url'],
- body=body,
- timeout=timeout,
- headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}'},
- )
+ return await connections.send(app, payload, timeout)
def get_app_response(app: AppModel) -> dict:
@@ -245,7 +227,7 @@ def get_app_response(app: AppModel) -> dict:
'name': app.name,
'is_active': app.is_active,
'configured': bool(app.api_key),
- 'data': app.data,
+ 'data': {k: v for k, v in app.data.items() if k not in ('connection_token', 'webui_url')},
'created_at': app.created_at,
'updated_at': app.updated_at,
}
@@ -262,28 +244,6 @@ async def get_app_by_id(app_id: str, db: AsyncSession, require_active: bool = Fa
return app
-async def get_verified_app(
- authorization: str = Header(default=''), db: AsyncSession = Depends(get_async_session)
-) -> AppModel:
- if not await Config.get('apps.enable'):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled')
- try:
- if not authorization.startswith('Bearer '):
- raise ValueError()
- token = authorization.removeprefix('Bearer ')
- if len(token) > 4096:
- raise ValueError()
- claims = jwt.decode(token, options={'verify_signature': False})
- app_id = claims.get('sub')
- if not isinstance(app_id, str) or len(app_id) > 100:
- raise ValueError()
- app = await get_app_by_id(app_id, db=db, require_active=True)
- verify_app_token(app, token)
- return app
- except (ValueError, KeyError, jwt.PyJWTError, HTTPException):
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid app credential') from None
-
-
async def has_app_access(app: AppModel, user: UserModel, db: AsyncSession) -> bool:
return user.role == 'admin' or await AccessGrants.has_access(user.id, 'app', app.id, 'read', db=db)
@@ -370,7 +330,11 @@ def get_app_message_data(message):
async def publish_app_message_event(message, kind):
# Model output is streamed into this row; only publish its completed update.
- if kind == 'channel.message.updated' and (message.meta or {}).get('model_id') and not (message.meta or {}).get('done'):
+ if (
+ kind == 'channel.message.updated'
+ and (message.meta or {}).get('model_id')
+ and not (message.meta or {}).get('done')
+ ):
return
value = deepcopy(get_app_message_data(message))
if kind == 'channel.message.deleted':
@@ -391,7 +355,7 @@ async def send_app_message_event(message, kind, created_at):
app = await Apps.get_app_by_id(app_id, db=db)
if not app or 'channels:messages:read' not in app.data.get('granted_scopes', []):
continue
- if not app.data.get('webui_url'):
+ if not app.data.get('installation_id'):
continue
try:
await check_app_channel_membership(app, message['channel_id'], db=db)
@@ -402,7 +366,7 @@ async def send_app_message_event(message, kind, created_at):
'type': kind,
'message': message,
'app_id': app.id,
- 'webui_url': app.data['webui_url'],
+ 'installation_id': app.data['installation_id'],
'channel': {'id': message['channel_id']},
}
envelope['event_token'] = create_app_context_token(app, 'event', envelope, expires_in=300)
@@ -565,3 +529,88 @@ async def generate_app_channel_reply(
message = AppChannelMessage(**{k: getattr(reply, k) for k in AppChannelMessage.model_fields})
value['message'] = await update_app_message(db, app, source['id'], message, f'"{source["updated_at"]}"')
return value
+
+
+async def get_app_channels(app: AppModel, db: AsyncSession):
+ if not await Config.get('channels.enable'):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Channels are disabled')
+ await get_app_by_id(app.id, require_active=True, db=db)
+ require_scope(app, 'channels:read')
+ channels = (
+ await db.scalars(
+ select(Channel)
+ .join(ChannelMember, Channel.id == ChannelMember.channel_id)
+ .where(
+ ChannelMember.app_id == app.id,
+ ChannelMember.is_active.is_(True),
+ Channel.deleted_at.is_(None),
+ Channel.archived_at.is_(None),
+ )
+ )
+ ).all()
+ result = []
+ for channel in channels:
+ channel_data = await Channels.get_channel_by_id(channel.id, db=db)
+ is_private = (
+ channel_data.is_private is True
+ if channel_data.type == 'group'
+ else not any(grant.principal_id == '*' for grant in channel_data.access_grants)
+ )
+ result.append({'id': channel.id, 'name': channel.name, 'type': channel.type, 'is_private': is_private})
+ return result
+
+
+async def authorize_app_execution(form_data: AppTokenForm, app: AppModel, db: AsyncSession):
+ value = decode_app_context_token(app, 'execution', form_data.token)
+ user = await Users.get_user_by_id(value['actor_id'], db=db)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Actor is unavailable')
+ await check_app_invocation_access(app, value['channel_id'], user, db=db)
+ require_scope(app, value['scope'])
+ return {
+ 'authorized': True,
+ 'operation_id': value['operation_id'],
+ 'actor': {'id': user.id, 'is_admin': user.role == 'admin'},
+ 'channel_id': value['channel_id'],
+ 'type': value.get('type'),
+ 'data': value.get('data'),
+ }
+
+
+async def verify_app_event(form_data: AppTokenForm, app: AppModel, db: AsyncSession):
+ value = decode_app_context_token(app, 'event', form_data.token)
+ await check_app_channel_membership(app, value['channel']['id'], db=db)
+ require_scope(app, 'channels:messages:read')
+ return {k: v for k, v in value.items() if k not in ('purpose', 'exp', 'nonce')}
+
+
+async def get_app_channel_messages(
+ app: AppModel,
+ db: AsyncSession,
+ channel_id: str,
+ before: str | None = None,
+ parent_id: str | None = None,
+ limit: int = 50,
+):
+ await check_app_channel_membership(app, channel_id, db=db)
+ require_scope(app, 'channels:messages:read')
+ query = select(Message).where(Message.channel_id == channel_id, Message.parent_id == parent_id)
+ if parent_id:
+ parent = await db.get(Message, parent_id)
+ if not parent or parent.channel_id != channel_id:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Thread not found')
+ if before:
+ cursor = await db.get(Message, before)
+ if not cursor or cursor.channel_id != channel_id or cursor.parent_id != parent_id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid message cursor')
+ query = query.where(
+ or_(
+ Message.created_at < cursor.created_at,
+ and_(Message.created_at == cursor.created_at, Message.id < cursor.id),
+ )
+ )
+ rows = (await db.scalars(query.order_by(Message.created_at.desc(), Message.id.desc()).limit(limit + 1))).all()
+ return {
+ 'messages': [get_app_message_data(row) for row in rows[:limit]],
+ 'next_cursor': rows[limit - 1].id if len(rows) > limit else None,
+ }
diff --git a/src/lib/apis/apps/index.ts b/src/lib/apis/apps/index.ts
index e86e9724a9..7fce6faf2a 100644
--- a/src/lib/apis/apps/index.ts
+++ b/src/lib/apis/apps/index.ts
@@ -1,6 +1,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type App = {
+ connection_status: 'connecting' | 'connected' | 'disconnected';
id: string;
slug: string;
name: string;
diff --git a/src/lib/components/admin/Settings/Apps.svelte b/src/lib/components/admin/Settings/Apps.svelte
index bf51ae47bd..92832695ed 100644
--- a/src/lib/components/admin/Settings/Apps.svelte
+++ b/src/lib/components/admin/Settings/Apps.svelte
@@ -16,12 +16,14 @@
let selected: App | null = null;
let show = false;
let busy = '';
- async function load() {
+ async function load(silent = false) {
try {
apps = await getApps(localStorage.token);
} catch (error) {
- toast.error(String(error));
- apps = [];
+ if (!silent) {
+ toast.error(String(error));
+ apps = [];
+ }
}
}
function configure(app: App | null) {
@@ -43,7 +45,11 @@
busy = '';
}
}
- onMount(load);
+ onMount(() => {
+ load();
+ const interval = setInterval(() => load(true), 5000);
+ return () => clearInterval(interval);
+ });