mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-14 04:08:46 +00:00
237 lines
8.1 KiB
Python
237 lines
8.1 KiB
Python
"""External apps. Credentials never leave the internal model."""
|
|
|
|
import base64
|
|
import re
|
|
import time
|
|
from typing import Literal
|
|
|
|
from open_webui.internal.db import Base, get_async_db_context
|
|
from open_webui.models.access_grants import AccessGrants
|
|
from open_webui.models.channels import ChannelMember
|
|
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, SecretStr, field_validator, model_validator
|
|
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
class App(Base):
|
|
__tablename__ = 'app'
|
|
|
|
id = Column(Text, primary_key=True)
|
|
slug = Column(Text, nullable=False, unique=True)
|
|
name = Column(Text, nullable=False)
|
|
user_id = Column(Text, nullable=True)
|
|
is_active = Column(Boolean, nullable=False, default=False)
|
|
data = Column(JSON, nullable=False)
|
|
api_key = Column(Text, nullable=True)
|
|
created_at = Column(BigInteger, nullable=False)
|
|
updated_at = Column(BigInteger, nullable=False)
|
|
|
|
|
|
class AppModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: str
|
|
slug: str
|
|
name: str
|
|
user_id: str | None = None
|
|
is_active: bool
|
|
data: dict
|
|
api_key: str | None = Field(default=None, exclude=True, repr=False)
|
|
created_at: int
|
|
updated_at: int
|
|
|
|
|
|
class StrictModel(BaseModel):
|
|
model_config = ConfigDict(extra='forbid')
|
|
|
|
|
|
class AppInvitation(StrictModel):
|
|
app_id: str
|
|
|
|
|
|
Scope = Literal['channels:mentions:read', 'channels:messages:read', 'channels:messages:write', 'channels:read']
|
|
|
|
|
|
class Manifest(StrictModel):
|
|
id: str = Field(min_length=1, max_length=100)
|
|
name: str = Field(min_length=1, max_length=100)
|
|
description: str = Field(default='', max_length=2000)
|
|
profile_image_url: str | None = Field(default=None, max_length=196608)
|
|
public_key: str = Field(max_length=2000)
|
|
requested_scopes: list[Scope]
|
|
|
|
@field_validator('profile_image_url')
|
|
@classmethod
|
|
def validate_profile_image_url(cls, value):
|
|
if value is not None:
|
|
if value.startswith('data:'):
|
|
header, encoded = value.split(',', 1)
|
|
if not re.fullmatch(r'data:image/(png|jpeg|gif|webp);base64', header):
|
|
raise ValueError('Expected a base64 PNG, JPEG, GIF or WebP data URL')
|
|
if not base64.b64decode(encoded, validate=True):
|
|
raise ValueError('Profile image data must not be empty')
|
|
else:
|
|
url = HttpUrl(value)
|
|
if len(value) > 2048 or url.username is not None or url.password is not None:
|
|
raise ValueError('Profile image URL must not contain credentials or exceed 2048 characters')
|
|
return value
|
|
|
|
@field_validator('public_key')
|
|
@classmethod
|
|
def validate_public_key(cls, value):
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
|
|
key = serialization.load_pem_public_key(value.encode())
|
|
if not isinstance(key, Ed25519PublicKey):
|
|
raise ValueError('An Ed25519 public key is required')
|
|
return key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()
|
|
|
|
|
|
class Action(StrictModel):
|
|
action_id: str = Field(min_length=1, max_length=100)
|
|
label: str = Field(min_length=1, max_length=200)
|
|
context: dict = Field(default_factory=dict)
|
|
|
|
|
|
class AppChannelMessage(StrictModel):
|
|
content: str = Field(default='', max_length=32000)
|
|
actions: list[Action] = Field(default_factory=list, max_length=30)
|
|
reply_to_id: str | None = None
|
|
parent_id: str | None = None
|
|
|
|
|
|
class AppChannelReply(AppChannelMessage):
|
|
type: Literal['message', 'update', 'ack', 'download', 'error'] = 'message'
|
|
visibility: Literal['private', 'channel'] = 'private'
|
|
title: str = Field(default='', max_length=200)
|
|
filename: str = Field(default='report.md', pattern=r'^[a-zA-Z0-9_.-]{1,100}\.md$')
|
|
|
|
@model_validator(mode='after')
|
|
def check_ack(self):
|
|
if self.type == 'ack' and (self.content or self.actions or self.title or self.parent_id or self.reply_to_id):
|
|
raise ValueError('Acknowledgements cannot contain a message')
|
|
return self
|
|
|
|
|
|
class AppInteractionForm(StrictModel):
|
|
message_id: str
|
|
action_id: str = Field(min_length=1, max_length=100)
|
|
|
|
|
|
####################
|
|
# Forms
|
|
####################
|
|
|
|
|
|
class AppManifestForm(StrictModel):
|
|
url: str = Field(max_length=2048)
|
|
api_key: SecretStr | None = Field(default=None, min_length=1, max_length=4096)
|
|
app_id: str | None = None
|
|
|
|
|
|
class AppForm(StrictModel):
|
|
manifest: Manifest
|
|
url: str = Field(max_length=2048)
|
|
api_key: SecretStr = Field(min_length=1, max_length=4096)
|
|
slug: str = Field(pattern=r'^[a-z][a-z0-9-]{0,39}$')
|
|
granted_scopes: list[Scope]
|
|
access_grants: list[dict] = Field(
|
|
default_factory=lambda: [{'principal_type': 'user', 'principal_id': '*', 'permission': 'read'}]
|
|
)
|
|
|
|
|
|
class AppUpdateForm(StrictModel):
|
|
is_active: bool | None = None
|
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
settings: dict | None = None
|
|
manifest: Manifest | None = None
|
|
granted_scopes: list[Scope] | None = None
|
|
access_grants: list[dict] | None = None
|
|
|
|
|
|
class AppTokenForm(StrictModel):
|
|
token: str = Field(max_length=1048576)
|
|
|
|
|
|
class AppConnectionForm(StrictModel):
|
|
api_key: SecretStr | None = Field(default=None, min_length=1, max_length=4096)
|
|
|
|
|
|
####################
|
|
# Responses
|
|
####################
|
|
|
|
|
|
class AppResponse(BaseModel):
|
|
connection_status: Literal['connecting', 'connected', 'disconnected'] = 'disconnected'
|
|
id: str
|
|
slug: str
|
|
name: str
|
|
is_active: bool
|
|
configured: bool
|
|
data: dict
|
|
created_at: int
|
|
updated_at: int
|
|
|
|
|
|
class AppAccessResponse(AppResponse):
|
|
access_grants: list[dict] = Field(default_factory=list)
|
|
|
|
|
|
class AppsTable:
|
|
async def get_app_by_id(self, id: str, db: AsyncSession | None = None) -> AppModel | None:
|
|
async with get_async_db_context(db) as db:
|
|
app = await db.scalar(select(App).where(App.id == id).execution_options(populate_existing=True))
|
|
return AppModel.model_validate(app) if app else None
|
|
|
|
async def get_app_ids_by_channel_id(
|
|
self, channel_id: str, joined_before: int, db: AsyncSession | None = None
|
|
) -> list[str]:
|
|
async with get_async_db_context(db) as db:
|
|
return list(
|
|
await db.scalars(
|
|
select(App.id)
|
|
.join(ChannelMember, ChannelMember.app_id == App.id)
|
|
.where(
|
|
App.is_active.is_(True),
|
|
ChannelMember.channel_id == channel_id,
|
|
ChannelMember.is_active.is_(True),
|
|
ChannelMember.joined_at <= joined_before,
|
|
)
|
|
)
|
|
)
|
|
|
|
async def update_app_by_id(
|
|
self,
|
|
id: str,
|
|
data: dict,
|
|
access_grants: list[dict] | None = None,
|
|
db: AsyncSession | None = None,
|
|
) -> AppModel | None:
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(update(App).where(App.id == id).values(**data))
|
|
if access_grants is not None:
|
|
await AccessGrants.replace_access_grants(db, 'app', id, access_grants)
|
|
await db.commit()
|
|
return await self.get_app_by_id(id, db=db)
|
|
|
|
async def uninstall_app_by_id(self, id: str, db: AsyncSession | None = None) -> AppModel | None:
|
|
async with get_async_db_context(db) as db:
|
|
app = await db.get(App, id)
|
|
if not app:
|
|
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())
|
|
)
|
|
await db.commit()
|
|
return AppModel.model_validate(app)
|
|
|
|
|
|
Apps = AppsTable()
|