mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-14 04:08:46 +00:00
295 lines
11 KiB
Python
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,
|
|
)
|