This commit is contained in:
Timothy Jaeryang Baek 2026-10-13 00:43:00 +04:00
parent 53a8f131f3
commit 1786d6cdb0
8 changed files with 619 additions and 321 deletions

View file

@ -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

View file

@ -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())

View file

@ -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'}
############################

View file

@ -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()

View file

@ -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,
}

View file

@ -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;

View file

@ -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);
});
</script>
<AppModal bind:show app={selected} onChange={load} />
@ -86,6 +92,15 @@
<span class="text-gray-400 dark:text-gray-600">{app.slug}</span></span
>
</div>
{#if app.is_active}
<span class="text-gray-400 dark:text-gray-500"
>{app.connection_status === 'connected'
? $i18n.t('Connected')
: app.connection_status === 'connecting'
? $i18n.t('Connecting')
: $i18n.t('Disconnected')}</span
>
{/if}
<div class="flex shrink-0 items-center gap-1">
<Tooltip content={$i18n.t('Configure')}
><button

View file

@ -2138,7 +2138,8 @@
text,
history?.currentId
? createMessagesList(history, history.currentId)
: null
: null,
'general'
).catch((error) => {
console.log(error);