refac: shared chat

This commit is contained in:
Timothy Jaeryang Baek
2026-04-17 10:16:32 +09:00
parent 4d2f189810
commit 2e52ad8ff2
7 changed files with 818 additions and 241 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 @@
</div>
{#if chat}
<div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center">
<div class=" text-sm dark:text-gray-300 mb-1">
<div class="px-5 pt-4 pb-5 w-full flex flex-col">
<div class="text-sm dark:text-gray-300">
{#if chat.share_id}
<a href="/s/{chat.share_id}" target="_blank"
>{$i18n.t('You have shared this chat')}
@@ -124,70 +153,73 @@
{/if}
</div>
<div class="flex justify-end">
<div class="flex flex-col items-end space-x-1 mt-3">
<div class="flex gap-1">
{#if $config?.features.enable_community_sharing}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
show = false;
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Oh, Safari, you're so special, let's give you some extra love and attention
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
show = false;
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
{#if chat.share_id}
<div class="mt-3">
<AccessControl
bind:accessGrants
accessRoles={['read']}
onChange={saveAccessGrants}
/>
</div>
{/if}
<div class="flex justify-end gap-1 mt-3">
{#if $config?.features.enable_community_sharing}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
</div>
{/if}