refac: shared chat
This commit is contained in:
@@ -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')
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user