diff --git a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py new file mode 100644 index 0000000000..ca2f9e7cd3 --- /dev/null +++ b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py @@ -0,0 +1,164 @@ +"""Add shared_chat table and migrate existing shares + +Revision ID: c1d2e3f4a5b6 +Revises: e1f2a3b4c5d6 +Create Date: 2026-04-16 23:00:00.000000 + +""" + +import time +import uuid + +from alembic import op +import sqlalchemy as sa + +revision = 'c1d2e3f4a5b6' +down_revision = 'e1f2a3b4c5d6' +branch_labels = None +depends_on = None + +# Lightweight table references for data migration (no ORM models needed) +chat_t = sa.table( + 'chat', + sa.column('id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('share_id', sa.Text), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), + sa.column('archived', sa.Boolean), + sa.column('meta', sa.JSON), +) + +shared_chat_t = sa.table( + 'shared_chat', + sa.column('id', sa.Text), + sa.column('chat_id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), +) + +chat_message_t = sa.table( + 'chat_message', + sa.column('chat_id', sa.Text), +) + +access_grant_t = sa.table( + 'access_grant', + sa.column('id', sa.Text), + sa.column('resource_type', sa.Text), + sa.column('resource_id', sa.Text), + sa.column('principal_type', sa.Text), + sa.column('principal_id', sa.Text), + sa.column('permission', sa.Text), + sa.column('created_at', sa.BigInteger), +) + + +def upgrade(): + conn = op.get_bind() + + # 1. Create shared_chat table + op.create_table( + 'shared_chat', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('chat', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + ) + + # 2. Migrate existing shared-* rows + shared_rows = conn.execute( + sa.select( + chat_t.c.id, + chat_t.c.user_id, + chat_t.c.title, + chat_t.c.chat, + chat_t.c.created_at, + chat_t.c.updated_at, + ).where(chat_t.c.user_id.like('shared-%')) + ).fetchall() + + for row in shared_rows: + share_token = row.id + original_chat_id = row.user_id.replace('shared-', '', 1) + + # Verify original chat still exists + original = conn.execute( + sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id) + ).fetchone() + + if not original: + continue + + # Insert snapshot into shared_chat + conn.execute(shared_chat_t.insert().values( + id=share_token, + chat_id=original_chat_id, + user_id=original.user_id, + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + )) + + # Create user:*:read grant for backward compat + conn.execute(access_grant_t.insert().values( + id=str(uuid.uuid4()), + resource_type='shared_chat', + resource_id=original_chat_id, + principal_type='user', + principal_id='*', + permission='read', + created_at=row.created_at or int(time.time()), + )) + + # 3. Clean up old phantom rows + conn.execute( + chat_message_t.delete().where( + chat_message_t.c.chat_id.in_( + sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')) + ) + ) + ) + conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%'))) + + +def downgrade(): + conn = op.get_bind() + + shared_rows = conn.execute( + sa.select( + shared_chat_t.c.id, + shared_chat_t.c.chat_id, + shared_chat_t.c.user_id, + shared_chat_t.c.title, + shared_chat_t.c.chat, + shared_chat_t.c.created_at, + shared_chat_t.c.updated_at, + ) + ).fetchall() + + for row in shared_rows: + conn.execute(chat_t.insert().values( + id=row.id, + user_id=f'shared-{row.chat_id}', + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + archived=False, + meta={}, + )) + + conn.execute( + access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat') + ) + op.drop_table('shared_chat') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 3bcfdce03f..0e1c6bef9d 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -555,77 +555,51 @@ class ChatTable: async def insert_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Create a shared snapshot for a chat. Returns the original chat with share_id set.""" + from open_webui.models.shared_chats import SharedChats + async with get_async_db_context(db) as db: - # Get the existing chat to share chat = await db.get(Chat, chat_id) - # Check if chat exists if not chat: return None - # Check if the chat is already shared - if chat.share_id: - return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db) - # Create a new chat with the same data, but with a new ID - shared_chat = ChatModel( - **{ - 'id': str(uuid.uuid4()), - 'user_id': f'shared-{chat_id}', - 'title': chat.title, - 'chat': chat.chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - 'created_at': chat.created_at, - 'updated_at': int(time.time()), - } - ) - shared_result = Chat(**shared_chat.model_dump()) - db.add(shared_result) - await db.commit() - await db.refresh(shared_result) - # Update the original chat with the share_id - await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id)) + # If already shared, just update the existing snapshot + if chat.share_id: + return await self.update_shared_chat_by_chat_id(chat_id, db=db) + + shared = await SharedChats.create(chat_id, chat.user_id, db=db) + if not shared: + return None + + # Set share_id on the original chat + chat.share_id = shared.id await db.commit() - return shared_chat if shared_result else None + await db.refresh(chat) + return ChatModel.model_validate(chat) async def update_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Re-snapshot the shared chat with current chat data.""" + from open_webui.models.shared_chats import SharedChats + try: async with get_async_db_context(db) as db: chat = await db.get(Chat, chat_id) - result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}')) - shared_chat = result.scalars().first() - - if shared_chat is None: + if not chat or not chat.share_id: return await self.insert_shared_chat_by_chat_id(chat_id, db=db) - shared_chat.title = chat.title - shared_chat.chat = chat.chat - shared_chat.meta = chat.meta - shared_chat.pinned = chat.pinned - shared_chat.folder_id = chat.folder_id - shared_chat.updated_at = int(time.time()) - await db.commit() - await db.refresh(shared_chat) - - return ChatModel.model_validate(shared_chat) + await SharedChats.update(chat.share_id, db=db) + return ChatModel.model_validate(chat) except Exception: return None async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete shared snapshot for a chat.""" + from open_webui.models.shared_chats import SharedChats + try: - async with get_async_db_context(db) as db: - # Get shared chat IDs - result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}')) - shared_ids = [row[0] for row in result.all()] - - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}')) - await db.commit() - - return True + return await SharedChats.delete_by_chat_id(chat_id, db=db) except Exception: return False @@ -746,53 +720,12 @@ class ChatTable: limit: int = 50, db: Optional[AsyncSession] = None, ) -> list[SharedChatResponse]: - async with get_async_db_context(db) as db: - stmt = ( - select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at) - .filter_by(user_id=user_id) - .filter(Chat.share_id.isnot(None)) - ) + """Delegate to SharedChats for listing shared chats by user.""" + from open_webui.models.shared_chats import SharedChats - if filter: - query_key = filter.get('query') - if query_key: - stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%')) - - order_by = filter.get('order_by') - direction = filter.get('direction') - - if order_by and direction: - if not getattr(Chat, order_by, None): - raise ValueError('Invalid order_by field') - - if direction.lower() == 'asc': - stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == 'desc': - stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) - else: - raise ValueError('Invalid direction for ordering') - else: - stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) - - if skip: - stmt = stmt.offset(skip) - if limit: - stmt = stmt.limit(limit) - - result = await db.execute(stmt) - all_chats = result.all() - return [ - SharedChatResponse.model_validate( - { - 'id': chat[0], - 'title': chat[1], - 'share_id': chat[2], - 'updated_at': chat[3], - 'created_at': chat[4], - } - ) - for chat in all_chats - ] + return await SharedChats.get_by_user_id( + user_id, filter=filter, skip=skip, limit=limit, db=db + ) async def get_chat_list_by_user_id( self, @@ -925,15 +858,23 @@ class ChatTable: return None async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: - try: - async with get_async_db_context(db) as db: - result = await db.execute(select(Chat).filter_by(share_id=id)) - chat = result.scalars().first() + """Look up a shared chat snapshot by its share token.""" + from open_webui.models.shared_chats import SharedChats - if chat: - return await self.get_chat_by_id(id, db=db) - else: - return None + try: + shared = await SharedChats.get_by_id(id, db=db) + if shared: + # Return a ChatModel-compatible view of the snapshot + return ChatModel( + id=shared.id, + user_id=shared.user_id, + title=shared.title, + chat=shared.chat, + created_at=shared.created_at, + updated_at=shared.updated_at, + share_id=shared.id, + ) + return None except Exception: return None @@ -1568,20 +1509,17 @@ class ChatTable: return False async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chat snapshots created by a user.""" + from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable + try: async with get_async_db_context(db) as db: - result = await db.execute(select(Chat.id).filter_by(user_id=user_id)) - id_rows = result.all() - shared_chat_ids = [f'shared-{row[0]}' for row in id_rows] + # Delete shared_chat rows for this user's chats + await db.execute(delete(SharedChatTable).filter_by(user_id=user_id)) - if shared_chat_ids: - # Get shared chat IDs to delete associated messages - shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids))) - shared_ids = [row[0] for row in shared_result.all()] - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids))) - await db.commit() + # Clear share_id on all of this user's chats + await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None)) + await db.commit() return True except Exception: @@ -1651,16 +1589,15 @@ class ChatTable: except Exception: return False - async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]: + async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]: + """Return IDs of chats that contain this file and have an active share link.""" async with get_async_db_context(db) as db: result = await db.execute( - select(Chat) + select(Chat.id) .join(ChatFile, Chat.id == ChatFile.chat_id) .filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None)) ) - all_chats = result.scalars().all() - - return [ChatModel.model_validate(chat) for chat in all_chats] + return [row[0] for row in result.all()] async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: """Update the tasks list on a chat.""" diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py new file mode 100644 index 0000000000..1a042922fb --- /dev/null +++ b/backend/open_webui/models/shared_chats.py @@ -0,0 +1,222 @@ +import logging +import time +import uuid +from typing import Optional + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON + +log = logging.getLogger(__name__) + +#################### +# SharedChat DB Schema +#################### + + +class SharedChat(Base): + __tablename__ = 'shared_chat' + + id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Text, nullable=False) # Who created this share + + title = Column(Text) + chat = Column(JSON) # Snapshot of chat JSON at share time + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class SharedChatModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + + title: str + chat: dict + + created_at: int + updated_at: int + + +class SharedChatResponse(BaseModel): + id: str + chat_id: str + title: str + share_id: Optional[str] = None # Alias for id, for backward compat + updated_at: int + created_at: int + + +#################### +# Table Operations +#################### + + +class SharedChatsTable: + async def create( + self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """ + Create a snapshot of the chat for link sharing. + Returns the SharedChatModel with the share token as its id. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + chat = await db.get(Chat, chat_id) + if not chat: + return None + + share_id = str(uuid.uuid4()) + now = int(time.time()) + + shared_chat = SharedChat( + id=share_id, + chat_id=chat_id, + user_id=user_id, + title=chat.title, + chat=chat.chat, + created_at=now, + updated_at=now, + ) + db.add(shared_chat) + await db.commit() + await db.refresh(shared_chat) + + return SharedChatModel.model_validate(shared_chat) + + async def update( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """ + Re-snapshot: update the shared chat with the current state of the original chat. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + shared_chat = await db.get(SharedChat, share_id) + if not shared_chat: + return None + + chat = await db.get(Chat, shared_chat.chat_id) + if not chat: + return None + + shared_chat.title = chat.title + shared_chat.chat = chat.chat + shared_chat.updated_at = int(time.time()) + + await db.commit() + await db.refresh(shared_chat) + return SharedChatModel.model_validate(shared_chat) + + async def get_by_id( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """Get a shared chat by its share token.""" + async with get_async_db_context(db) as db: + shared_chat = await db.get(SharedChat, share_id) + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """Get the shared chat for a given original chat. Returns the most recent one.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(SharedChat) + .filter_by(chat_id=chat_id) + .order_by(SharedChat.updated_at.desc()) + .limit(1) + ) + shared_chat = result.scalars().first() + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_user_id( + self, + user_id: str, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 50, + db: Optional[AsyncSession] = None, + ) -> list[SharedChatResponse]: + """List all shared chats created by a user.""" + async with get_async_db_context(db) as db: + stmt = select(SharedChat).filter_by(user_id=user_id) + + if filter: + query_key = filter.get('query') + if query_key: + stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%')) + + order_by = filter.get('order_by') + direction = filter.get('direction') + + if order_by and direction: + col = getattr(SharedChat, order_by, None) + if not col: + raise ValueError('Invalid order_by field') + if direction.lower() == 'asc': + stmt = stmt.order_by(col.asc()) + elif direction.lower() == 'desc': + stmt = stmt.order_by(col.desc()) + else: + raise ValueError('Invalid direction for ordering') + else: + stmt = stmt.order_by(SharedChat.updated_at.desc()) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + return [ + SharedChatResponse( + id=sc.chat_id, + chat_id=sc.chat_id, + title=sc.title, + share_id=sc.id, + updated_at=sc.updated_at, + created_at=sc.created_at, + ) + for sc in result.scalars().all() + ] + + async def delete_by_id( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> bool: + """Delete a shared chat by its share token.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(id=share_id)) + await db.commit() + return True + except Exception: + return False + + async def delete_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> bool: + """Delete all shared chats for a given original chat.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(chat_id=chat_id)) + await db.commit() + return True + except Exception: + return False + + +SharedChats = SharedChatsTable() diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 1980d22362..02f9a74662 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -17,13 +17,14 @@ from open_webui.models.chats import ( ChatResponse, Chats, ChatTitleIdResponse, - SharedChatResponse, ChatStatsExport, AggregateChatStats, ChatBody, ChatHistoryStats, MessageStats, ) +from open_webui.models.shared_chats import SharedChats, SharedChatResponse +from open_webui.models.access_grants import AccessGrants from open_webui.models.tags import TagModel, Tags from open_webui.models.folders import Folders from open_webui.internal.db import get_async_session @@ -35,7 +36,7 @@ from pydantic import BaseModel from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants log = logging.getLogger(__name__) @@ -807,7 +808,7 @@ async def get_shared_session_user_chat_list( if direction: filter['direction'] = direction - return await Chats.get_shared_chat_list_by_user_id( + return await SharedChats.get_by_user_id( user.id, filter=filter, skip=skip, @@ -828,17 +829,32 @@ async def get_shared_chat_by_id( if user.role == 'pending': raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS): - chat = await Chats.get_chat_by_share_id(share_id, db=db) - elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) - - if chat: - return ChatResponse(**chat.model_dump()) - else: + chat = await Chats.get_chat_by_share_id(share_id, db=db) + + if not chat: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + # Look up the original chat_id to check access grants + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return ChatResponse(**chat.model_dump()) + ############################ # GetChatsByTags @@ -878,11 +894,25 @@ async def get_user_chat_list_by_tag_name( async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + # Check if user has access via access grants (shared_chat grants) + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) + else: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) + if chat: return ChatResponse(**chat.model_dump()) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1158,39 +1188,58 @@ async def clone_shared_chat_by_id( else: chat = await Chats.get_chat_by_share_id(id, db=db) - if chat: - updated_chat = { - **chat.chat, - 'originalChatId': chat.id, - 'branchPointMessageId': chat.chat['history']['currentId'], - 'title': f'Clone of {chat.title}', - } - - chats = await Chats.import_chats( - user.id, - [ - ChatImportForm( - **{ - 'chat': updated_chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - } - ) - ], - db=db, + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, ) - if chats: - chat = chats[0] - return ChatResponse(**chat.model_dump()) - else: + # Enforce access grants + shared = await SharedChats.get_by_id(id, db=db) + if shared and user.role != 'admin': + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(), + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + + updated_chat = { + **chat.chat, + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': f'Clone of {chat.title}', + } + + chats = await Chats.import_chats( + user.id, + [ + ChatImportForm( + **{ + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, + } + ) + ], + db=db, + ) + + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) ############################ @@ -1241,16 +1290,28 @@ async def share_chat_by_id( if chat: if chat.share_id: - shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db) - return ChatResponse(**shared_chat.model_dump()) + # Re-snapshot existing share + shared = await SharedChats.update(chat.share_id, db=db) + if shared: + # Re-fetch the original chat to return + chat = await Chats.get_chat_by_id(id, db=db) + return ChatResponse(**chat.model_dump()) - shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db) - if not shared_chat: + # Create new share + shared = await SharedChats.create(id, user.id, db=db) + if not shared: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(), ) - return ChatResponse(**shared_chat.model_dump()) + # Set share_id on the original chat + chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -1260,7 +1321,7 @@ async def share_chat_by_id( ############################ -# DeletedSharedChatById +# DeleteSharedChatById ############################ @@ -1273,10 +1334,13 @@ async def delete_shared_chat_by_id( if not chat.share_id: return False - result = await Chats.delete_shared_chat_by_chat_id(id, db=db) - update_result = await Chats.update_chat_share_id_by_id(id, None, db=db) + await SharedChats.delete_by_chat_id(id, db=db) + await Chats.update_chat_share_id_by_id(id, None, db=db) - return result and update_result != None + # Revoke all access grants for this shared chat + await AccessGrants.set_access_grants('shared_chat', id, [], db=db) + + return True else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1284,6 +1348,85 @@ async def delete_shared_chat_by_id( ) +############################ +# UpdateSharedChatAccessById +############################ + + +class ChatAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse]) +async def update_shared_chat_access_by_id( + request: Request, + id: str, + form_data: ChatAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_chats', + ) + + await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) + + return ChatResponse(**chat.model_dump()) + + +############################ +# GetSharedChatAccessById +############################ + + +@router.get('/shared/{id}/access', response_model=list) +async def get_shared_chat_access_by_id( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db) + return [ + { + 'id': g.id, + 'principal_type': g.principal_type, + 'principal_id': g.principal_id, + 'permission': g.permission, + } + for g in grants + ] + + ############################ # UpdateChatFolderIdById ############################ diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index 5e7efb5b26..a48dfeb0f1 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -66,10 +66,18 @@ async def has_access_to_file( return True # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats - chats = await Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True + shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db) + if shared_chat_ids: + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='shared_chat', + resource_ids=shared_chat_ids, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + if accessible_ids: + return True # Check if the file is directly attached to a shared workspace model for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index e16746707a..751fc823f2 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -953,6 +953,77 @@ export const deleteSharedChatById = async (token: string, id: string) => { return res; }; +export const updateChatAccessGrants = async ( + token: string, + id: string, + accessGrants: object[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getChatAccessGrants = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateChatById = async (token: string, id: string, chat: object) => { let error = null; diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte index 1c02f6481d..c48519e4ca 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -3,24 +3,32 @@ import { models, config } from '$lib/stores'; import { toast } from 'svelte-sonner'; - import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats'; + import { + deleteSharedChatById, + getChatById, + shareChatById, + getChatAccessGrants, + updateChatAccessGrants + } from '$lib/apis/chats'; import { copyToClipboard } from '$lib/utils'; import Modal from '../common/Modal.svelte'; import Link from '../icons/Link.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; + import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; export let chatId; let chat = null; let shareUrl = null; + let accessGrants: any[] = []; const i18n = getContext('i18n'); const shareLocalChat = async () => { const _chat = chat; const sharedChat = await shareChatById(localStorage.token, chatId); - shareUrl = `${window.location.origin}/s/${sharedChat.id}`; + shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`; console.log(shareUrl); chat = await getChatById(localStorage.token, chatId); @@ -54,6 +62,25 @@ ); }; + const loadAccessGrants = async () => { + if (!chatId) return; + try { + accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? []; + } catch (e) { + console.error('Failed to load access grants', e); + accessGrants = []; + } + }; + + const saveAccessGrants = async () => { + try { + await updateChatAccessGrants(localStorage.token, chatId, accessGrants); + toast.success($i18n.t('Access updated')); + } catch (e) { + toast.error(`${e}`); + } + }; + export let show = false; const isDifferentChat = (_chat) => { @@ -73,8 +100,10 @@ if (isDifferentChat(_chat)) { chat = _chat; } + await loadAccessGrants(); } else { chat = null; + accessGrants = []; console.log(chat); } })(); @@ -97,8 +126,8 @@ {#if chat} -