open-webui/backend/open_webui/routers/apps.py
Timothy Jaeryang Baek 4ca8fdc3fd refac
2026-10-13 01:55:12 +04:00

295 lines
11 KiB
Python

"""Native external apps: administration, channel participation and authenticated interactions."""
import time
import uuid
from fastapi import APIRouter, Depends, 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,
AppConnectionForm,
AppForm,
AppInteractionForm,
AppManifestForm,
AppResponse,
Apps,
AppUpdateForm,
Manifest,
)
from open_webui.models.config import Config
from open_webui.models.messages import Message
from open_webui.utils.apps import (
check_app_invocation_access,
generate_app_channel_reply,
get_app_by_id,
get_app_encryption_cipher,
get_app_response,
has_app_access,
send_app_request,
validate_app_url,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
############################
# GetApps
############################
@router.get('', response_model=list[AppResponse])
async def get_apps(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
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), '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))
]
############################
# PreviewAppManifest
############################
@router.post('/manifest/preview', response_model=Manifest)
async def preview_app_manifest(
form_data: AppManifestForm, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
if not await Config.get('apps.enable'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled')
api_key = form_data.api_key.get_secret_value() if form_data.api_key is not None else None
if form_data.app_id:
app = await get_app_by_id(form_data.app_id, db=db)
if form_data.url != app.data['url']:
raise HTTPException(400, 'App URL does not match the installed app')
if api_key is None and app.api_key:
api_key = get_app_encryption_cipher().decrypt(app.api_key.encode()).decode()
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}'})
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:
raise HTTPException(
502, f'App manifest endpoint returned HTTP {status_code}. Check the URL or enter details manually.'
)
manifest = Manifest.model_validate(raw)
return manifest.model_dump()
except HTTPException:
raise
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='App returned an invalid manifest. Check its app details or enter them manually.',
) from None
############################
# CreateNewApp
############################
@router.post('', response_model=AppResponse)
async def create_new_app(
form_data: AppForm, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
if not await Config.get('apps.enable'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled')
try:
validate_app_url(form_data.url)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid app URL') from None
if not set(form_data.granted_scopes) <= set(form_data.manifest.requested_scopes):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unrequested scope')
now = time.time_ns()
app = App(
id=str(uuid.uuid4()),
slug=form_data.slug,
name=form_data.manifest.name,
user_id=user.id,
is_active=False,
data={
'url': form_data.url,
'manifest': form_data.manifest.model_dump(),
'granted_scopes': form_data.granted_scopes,
'settings': {},
},
api_key=get_app_encryption_cipher().encrypt(form_data.api_key.get_secret_value().encode()).decode(),
created_at=now,
updated_at=now,
)
db.add(app)
try:
await AccessGrants.replace_access_grants(db, 'app', app.id, form_data.access_grants)
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='App slug is already registered') from None
return get_app_response(app)
############################
# GetAppDetailsById
############################
@router.get('/{app_id}', response_model=AppAccessResponse)
async def get_app_details_by_id(
app_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
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 (
await db.scalars(
select(AccessGrant).where(AccessGrant.resource_type == 'app', AccessGrant.resource_id == app_id)
)
).all()
]
return result
############################
# UpdateAppById
############################
@router.patch('/{app_id}', response_model=AppResponse)
async def update_app_by_id(
app_id: str, form_data: AppUpdateForm, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
app = await get_app_by_id(app_id, db=db)
if form_data.is_active is True:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Connect the app to enable it')
data = {'updated_at': max(time.time_ns(), app.updated_at + 1)}
if form_data.is_active is False:
data['is_active'] = False
if form_data.name is not None:
data['name'] = form_data.name
if form_data.settings is not None:
data['data'] = {**app.data, 'settings': form_data.settings}
if form_data.manifest is not None:
if form_data.manifest.id != app.data['manifest']['id']:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='App identity cannot change')
if form_data.granted_scopes is None or not set(form_data.granted_scopes) <= set(
form_data.manifest.requested_scopes
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Review the updated scopes')
data['data'] = {
**data.get('data', app.data),
'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']):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unrequested scope')
data['data'] = {**data.get('data', app.data), 'granted_scopes': form_data.granted_scopes}
app = await Apps.update_app_by_id(app_id, data, access_grants=form_data.access_grants, db=db)
return get_app_response(app)
############################
# ConnectAppById
############################
@router.post('/{app_id}/connect', response_model=AppResponse)
async def connect_app_by_id(
app_id: str,
form_data: AppConnectionForm,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
app = await get_app_by_id(app_id, db=db)
from open_webui.socket.apps import connections
if form_data.api_key is not None:
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(400, 'An app API key is required')
app = await Apps.update_app_by_id(
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,
)
app = await connections.connect(app)
return {**get_app_response(app), 'connection_status': 'connected'}
############################
# UninstallAppById
############################
@router.delete('/{app_id}', response_model=AppResponse)
async def uninstall_app_by_id(app_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
app = await get_app_by_id(app_id, db=db)
app = await Apps.uninstall_app_by_id(app_id, db=db)
return get_app_response(app)
############################
# CreateAppInteraction
############################
@router.post('/{app_id}/interactions')
async def create_app_interaction(
app_id: str,
form_data: AppInteractionForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
app = await get_app_by_id(app_id, require_active=True, db=db)
message = await db.get(Message, form_data.message_id)
if not message or message.app_id != app.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='App message not found')
await check_app_invocation_access(app, message.channel_id, user, db=db)
actions = (message.data or {}).get('app', {}).get('actions', [])
action = next((item for item in actions if item['action_id'] == form_data.action_id), None)
if not action:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unknown action')
return await generate_app_channel_reply(
app,
user,
message.channel_id,
'channel.action',
{
'message_id': message.id,
'etag': str(message.updated_at),
'parent_id': message.parent_id,
'action_id': form_data.action_id,
'context': action['context'],
},
source={'id': message.id, 'updated_at': message.updated_at},
db=db,
)