diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 9361da9ad9..6322e1c161 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -625,6 +625,7 @@ class KnowledgeTable: db=db, ), breadcrumbs=await self.get_directory_breadcrumbs( + knowledge_id, filter.get('directory_id') if filter else None, db=db, ), @@ -908,6 +909,7 @@ class KnowledgeTable: async def get_directory_breadcrumbs( self, + knowledge_id: str, directory_id: Optional[str], db: Optional[AsyncSession] = None, ) -> list[KnowledgeDirectoryModel]: @@ -922,7 +924,10 @@ class KnowledgeTable: while current_id and current_id not in seen: seen.add(current_id) - result = await db.execute(select(KnowledgeDirectory).filter_by(id=current_id)) + # Scoped by knowledge base so a caller-supplied id cannot walk another one's tree. + result = await db.execute( + select(KnowledgeDirectory).filter_by(id=current_id, knowledge_id=knowledge_id) + ) directory = result.scalars().first() if not directory: break diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index c2a429a3db..650249849f 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -224,6 +224,15 @@ async def process_uploaded_file( f'{knowledge_id}: user {user.id} lacks write access' ) else: + directory_id = file_metadata.get('directory_id') or None + if directory_id: + directory = await Knowledges.get_directory_by_id(directory_id, db=db_session) + if not directory or directory.knowledge_id != knowledge_id: + log.warning( + 'Ignoring directory %s: not a directory of knowledge %s', directory_id, knowledge_id + ) + directory_id = None + # Keep the generic file status stream open until the # KB-specific vector write and durable link both finish. await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session) @@ -237,7 +246,7 @@ async def process_uploaded_file( knowledge_id=knowledge_id, file_id=file_item.id, user_id=user.id, - directory_id=file_metadata.get('directory_id'), + directory_id=directory_id, db=db_session, ) if not knowledge_file: diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 43ec07a53c..84008db221 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -151,6 +151,24 @@ def external_knowledge_error(): ) +async def _verify_directory_in_knowledge( + id: str, + directory_id: str | None, + db: AsyncSession, + detail: str = ERROR_MESSAGES.NOT_FOUND, +): + """Verify a caller-supplied directory belongs to the knowledge base in the URL. Unset means the root level.""" + if not directory_id: + return None + + directory = await Knowledges.get_directory_by_id(directory_id, db=db) + if not directory or directory.knowledge_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) + + @router.get('/', response_model=KnowledgeAccessListResponse) async def get_knowledge_bases( page: int | None = 1, @@ -1437,6 +1455,8 @@ async def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + await _verify_directory_in_knowledge(id, form_data.directory_id, db, detail='Target directory not found.') + file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( @@ -2049,6 +2069,9 @@ async def add_files_to_knowledge_batch( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + for directory_id in {form.directory_id for form in form_data if form.directory_id}: + await _verify_directory_in_knowledge(id, directory_id, db, detail='Target directory not found.') + # Batch-fetch all files to avoid N+1 queries log.info('files/batch/add - %s files', len(form_data)) file_ids = [form.file_id for form in form_data] @@ -2237,6 +2260,8 @@ async def create_knowledge_directory( ): await _verify_knowledge_write_access(id, user, db) + await _verify_directory_in_knowledge(id, form_data.parent_id, db, detail='Parent directory not found.') + directory = await Knowledges.create_directory( knowledge_id=id, name=form_data.name, @@ -2269,14 +2294,11 @@ async def update_knowledge_directory( db: AsyncSession = Depends(get_async_session), ): await _verify_knowledge_write_access(id, user, db) + await _verify_directory_in_knowledge(id, dir_id, db) - # Verify directory belongs to this knowledge base - directory = await Knowledges.get_directory_by_id(dir_id, db=db) - if not directory or directory.knowledge_id != id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # '__unset__' leaves the parent alone, None moves the directory to the root + if form_data.parent_id not in (None, '__unset__'): + await _verify_directory_in_knowledge(id, form_data.parent_id, db, detail='Parent directory not found.') result = await Knowledges.update_directory( directory_id=dir_id, @@ -2309,14 +2331,7 @@ async def delete_knowledge_directory( db: AsyncSession = Depends(get_async_session), ): await _verify_knowledge_write_access(id, user, db) - - # Verify directory belongs to this knowledge base - directory = await Knowledges.get_directory_by_id(dir_id, db=db) - if not directory or directory.knowledge_id != id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + await _verify_directory_in_knowledge(id, dir_id, db) # Collect before delete_directory drops the KnowledgeFile rows files = [] if move_files else await Knowledges.get_files_by_id_and_directory_id(id, dir_id, db=db) @@ -2375,14 +2390,7 @@ async def move_file_in_knowledge( detail=ERROR_MESSAGES.NOT_FOUND, ) - # If target directory is set, verify it belongs to this knowledge base - if form_data.directory_id: - directory = await Knowledges.get_directory_by_id(form_data.directory_id, db=db) - if not directory or directory.knowledge_id != id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail='Target directory not found.', - ) + await _verify_directory_in_knowledge(id, form_data.directory_id, db, detail='Target directory not found.') success = await Knowledges.move_file_to_directory( knowledge_id=id, diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index a3c0e78be3..16983b53f6 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -26,7 +26,7 @@ import { createMessagesList } from '$lib/utils'; import { getOutputText } from '$lib/components/chat/Messages/structuredOutput'; import { config, user, chatId as currentChatId, tags } from '$lib/stores'; - import { refreshChatList } from '$lib/stores/chatList'; + import { refreshSidebar } from '$lib/stores/chatList'; import Messages from '../chat/Messages.svelte'; import { goto } from '$app/navigation'; import EditPencilIcon from './Sidebar/icons/EditPencil.svelte'; @@ -65,10 +65,6 @@ }; let generating = false; - const refreshSidebar = async () => { - await refreshChatList(localStorage.token, { refreshPinned: true }); - }; - const cloneChatHandler = async (id) => { const chat = chatList?.find((c) => c.id === id); const res = await cloneChatById( @@ -83,7 +79,7 @@ }); if (res) { - await refreshSidebar(); + await refreshSidebar(localStorage.token); await searchHandler(); } }; @@ -95,7 +91,7 @@ }); if (res) { - await refreshSidebar(); + await refreshSidebar(localStorage.token); } }; @@ -110,7 +106,7 @@ currentChatId.set(''); } - await refreshSidebar(); + await refreshSidebar(localStorage.token); toast.success(res?.archived ? $i18n.t('Chat archived.') : $i18n.t('Chat unarchived.')); } catch (error) { toast.error($i18n.t('Failed to archive chat.')); @@ -132,7 +128,7 @@ currentChatId.set(''); } - await refreshSidebar(); + await refreshSidebar(localStorage.token); } }; @@ -147,7 +143,7 @@ if (res) { chatList = chatList?.filter((c) => c.id !== chatId) ?? null; - await refreshSidebar(); + await refreshSidebar(localStorage.token); toast.success($i18n.t('Chat moved successfully')); } } @@ -182,7 +178,7 @@ editingChatId = null; editingChatTitle = ''; - await refreshSidebar(); + await refreshSidebar(localStorage.token); }; const cancelRename = () => { @@ -841,7 +837,7 @@ }} onClose={() => {}} onPinChange={async () => { - await refreshSidebar(); + await refreshSidebar(localStorage.token); await searchHandler(); }} > diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 54eba10ef3..8793b06685 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -41,7 +41,7 @@ settings, user } from '$lib/stores'; - import { refreshChatList } from '$lib/stores/chatList'; + import { refreshChatList, refreshSidebar } from '$lib/stores/chatList'; import ChatMenu from './ChatMenu.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; @@ -241,7 +241,7 @@ if (res) { goto(`/c/${res.id}`); - await refreshChatList(localStorage.token, { refreshPinned: true }); + await refreshSidebar(localStorage.token); } };