diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py
index 6027545190..1cfa2cc041 100644
--- a/backend/open_webui/routers/files.py
+++ b/backend/open_webui/routers/files.py
@@ -412,7 +412,12 @@ async def delete_all_files(user=Depends(get_admin_user), db: Session = Depends(g
@router.get('/{id}', response_model=Optional[FileModel])
-async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
+async def get_file_by_id(
+ id: str,
+ share_id: Optional[str] = Query(None),
+ user=Depends(get_verified_user),
+ db: Session = Depends(get_session),
+):
file = Files.get_file_by_id(id, db=db)
if not file:
@@ -421,7 +426,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session =
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
return file
else:
raise HTTPException(
@@ -434,6 +439,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session =
async def get_file_process_status(
id: str,
stream: bool = Query(False),
+ share_id: Optional[str] = Query(None),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
@@ -445,7 +451,7 @@ async def get_file_process_status(
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2
@@ -495,7 +501,12 @@ async def get_file_process_status(
@router.get('/{id}/data/content')
-async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
+async def get_file_data_content_by_id(
+ id: str,
+ share_id: Optional[str] = Query(None),
+ user=Depends(get_verified_user),
+ db: Session = Depends(get_session),
+):
file = Files.get_file_by_id(id, db=db)
if not file:
@@ -504,7 +515,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user),
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
return {'content': file.data.get('content', '')}
else:
raise HTTPException(
@@ -587,6 +598,7 @@ async def get_file_content_by_id(
id: str,
user=Depends(get_verified_user),
attachment: bool = Query(False),
+ share_id: Optional[str] = Query(None),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id, db=db)
@@ -597,7 +609,7 @@ async def get_file_content_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
@@ -646,7 +658,12 @@ async def get_file_content_by_id(
@router.get('/{id}/content/html')
-async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
+async def get_html_file_content_by_id(
+ id: str,
+ share_id: Optional[str] = Query(None),
+ user=Depends(get_verified_user),
+ db: Session = Depends(get_session),
+):
file = Files.get_file_by_id(id, db=db)
if not file:
@@ -662,7 +679,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user),
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
@@ -693,7 +710,12 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user),
@router.get('/{id}/content/{file_name}')
-async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
+async def get_file_content_by_id(
+ id: str,
+ share_id: Optional[str] = Query(None),
+ user=Depends(get_verified_user),
+ db: Session = Depends(get_session),
+):
file = Files.get_file_by_id(id, db=db)
if not file:
@@ -702,7 +724,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S
detail=ERROR_MESSAGES.NOT_FOUND,
)
- if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db):
+ if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db):
file_path = file.path
# Handle Unicode filenames
diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py
index a7e35fd506..59bd148117 100644
--- a/backend/open_webui/utils/access_control/files.py
+++ b/backend/open_webui/utils/access_control/files.py
@@ -18,6 +18,7 @@ def has_access_to_file(
file_id: str | None,
access_type: str,
user: UserModel,
+ share_id: str | None = None,
db: Session | None = None,
) -> bool:
"""
@@ -25,7 +26,7 @@ def has_access_to_file(
- Knowledge bases (ownership or access grants)
- Shared workspace models that attach the file directly
- Channels the user is a member of
- - Shared chats
+ - Shared chats (requires valid share_id or chat ownership)
NOTE: This does NOT check direct file ownership — callers should check
file.user_id == user.id separately before calling this.
@@ -65,11 +66,17 @@ def has_access_to_file(
if access_type == 'read' and channels:
return True
- # Check if the file is associated with any chats the user has access to
- # TODO: Granular access control for chats
+ # Check if the file is associated with any shared chats the user can access.
+ # Access is granted only when:
+ # 1. The caller provides a valid share_id that matches a shared chat
+ # containing this file (proves they have the share link), OR
+ # 2. The caller owns the shared chat containing this file.
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
- if chats:
- return True
+ for chat in chats:
+ if chat.user_id == user.id:
+ return True
+ if share_id and access_type == 'read' and chat.share_id == share_id:
+ return True
# Check if the file is directly attached to a shared workspace model
for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db):
diff --git a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte
index da9690c631..6c759eaa2a 100644
--- a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte
+++ b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte
@@ -3,7 +3,7 @@
import type { Token } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants';
- import { settings } from '$lib/stores';
+ import { settings, shareId } from '$lib/stores';
export let id: string;
export let token: Token;
@@ -109,7 +109,7 @@
{#if fileId}
{:else if token.type === 'iframe'}