diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 58469f05bb..1e5c60c5b4 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -2531,7 +2531,8 @@ class ChatTable: chat = await session.get(Chat, id) chat.folder_id = folder_id chat.updated_at = int(time.time()) - chat.last_read_at = int(time.time()) + if chat.user_id == user_id: + chat.last_read_at = int(time.time()) chat.pinned = False if folder_id is not None: # Folder listings only show unarchived chats, so moving an archived diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index e5bdf1916b..37b0a0b24d 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -40,7 +40,7 @@ from open_webui.models.tags import TagModel, Tags from open_webui.socket.main import get_event_emitter from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission -from open_webui.utils.access_control.folders import has_folder_write_access +from open_webui.utils.access_control.folders import has_chat_move_access, has_folder_write_access from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user from open_webui.utils.chat_fork import build_fork_history from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage @@ -1452,7 +1452,20 @@ async def update_chat_by_id( 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) + chat = await Chats.get_chat_by_id(id, db=db) + if chat and chat.user_id != user.id: + folder = await Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id, db=db) if chat.folder_id else None + if ( + not folder + or set(form_data.chat) != {'title'} + or form_data.variables is not None + or form_data.folder_id is not None + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + title = form_data.chat['title'] + if not isinstance(title, str) or not title.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Invalid title')) + form_data.chat['title'] = title.strip() if chat: touch = 'history' in form_data.chat or 'messages' in form_data.chat chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch) @@ -1474,6 +1487,8 @@ async def update_chat_by_id( subject_id=id, data={'title': chat.title}, ) + if chat.user_id != user.id: + return await shared_chat_response(chat, user, db=db) return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException( @@ -2291,7 +2306,10 @@ async def update_chat_folder_id_by_id( 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) + chat = await Chats.get_chat_by_id(id, db=db) + if chat and chat.user_id != user.id: + if not await has_chat_move_access(user.id, chat.folder_id, form_data.folder_id, db=db): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) if chat: # None is allowed: it moves the chat out of any folder. if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db): @@ -2308,6 +2326,8 @@ async def update_chat_folder_id_by_id( subject_id=id, data={'folder_id': form_data.folder_id}, ) + if chat.user_id != user.id: + return await shared_chat_response(chat, user, db=db) return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) diff --git a/backend/open_webui/utils/access_control/folders.py b/backend/open_webui/utils/access_control/folders.py index 1a632ba01b..8bb2433309 100644 --- a/backend/open_webui/utils/access_control/folders.py +++ b/backend/open_webui/utils/access_control/folders.py @@ -32,3 +32,28 @@ async def has_folder_write_access(user_id: str, folder_id: str, db: AsyncSession if not folder: return False return await has_folder_access(user_id, folder, 'write', db) + + +async def has_chat_move_access( + user_id: str, source_folder_id: str | None, target_folder_id: str | None, db: AsyncSession | None = None +) -> bool: + """Only the owner may reorganize another user's chat within the same folder tree.""" + + async def get_owned_folder_root_id(folder_id): + seen_ids = set() + shared_root_id = None + while folder_id and folder_id not in seen_ids: + seen_ids.add(folder_id) + folder = await Folders.get_folder_by_id(folder_id, db=db) + if not folder or folder.user_id != user_id: + return None + if await AccessGrants.get_grants_by_resource('folder', folder.id, db=db): + shared_root_id = folder.id + if not folder.parent_id: + # A private parent must not let moves escape a shared subtree. + return shared_root_id or folder.id + folder_id = folder.parent_id + return None + + source_root_id = await get_owned_folder_root_id(source_folder_id) + return source_root_id is not None and source_root_id == await get_owned_folder_root_id(target_folder_id) diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 3249ecbedf..b6d62f30d8 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -57,6 +57,7 @@ import ChatParticipants from '$lib/components/common/ChatParticipants.svelte'; import ChatIcon from './icons/Chat.svelte'; import MoreHorizontalIcon from './icons/MoreHorizontal.svelte'; + import EditPencilIcon from './icons/EditPencil.svelte'; import SparklesIcon from './icons/Sparkles.svelte'; import ArchiveBoxIcon from '$lib/components/icons/ArchiveBox.svelte'; import GarbageBinIcon from '$lib/components/icons/GarbageBin.svelte'; @@ -96,6 +97,8 @@ export let selected = false; export let shiftKey = false; export let readonly = false; + export let canMove = false; + export let canRename = false; export let ownerName: string | null = null; export let ownerUserId: string | null = null; @@ -211,7 +214,7 @@ }; const renameChatFromDoubleClick = async (e: MouseEvent) => { - if (readonly || selecting || e.shiftKey) return; + if ((readonly && !canRename) || selecting || e.shiftKey) return; e.preventDefault(); e.stopPropagation(); @@ -249,9 +252,13 @@ if (!title) { toast.error($i18n.t('Title cannot be an empty string.')); } else { - await updateChatById(localStorage.token, id, { + const res = await updateChatById(localStorage.token, id, { title: title + }).catch((error) => { + toast.error(`${error}`); + return null; }); + if (!res) return; if (id === $chatId) { _chatTitle.set(title); @@ -573,7 +580,9 @@ dir="auto" class="text-left self-center overflow-hidden w-full h-5 truncate {unread ? 'font-normal text-gray-800 dark:text-gray-200' - : ''} {($mobile || showInlineActions) && !readonly && !selecting ? 'pr-12' : ''}" + : ''} {($mobile || showInlineActions) && (!readonly || canRename) && !selecting + ? 'pr-12' + : ''}" > {title} @@ -588,7 +597,7 @@ {/if} - {#if (updatedAt ?? createdAt) && !showInlineActions && !($mobile && !readonly)} + {#if (updatedAt ?? createdAt) && !showInlineActions && !($mobile && (!readonly || canRename))}
{/if} - {#if !readonly && !selecting} + {#if (!readonly || canRename) && !selecting}