Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/chainlit/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,8 @@ def on_shared_thread_view(
"""Hook to authorize viewing a shared thread.

Users must implement and return True to allow a non-author to view a thread.
Thread metadata contains "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing.
This callback is the sole gatekeeper for the GET /project/share/{thread_id} endpoint.
Thread metadata may contain "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing.
Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool
Comment on lines -543 to 550
"""
config.code.on_shared_thread_view = wrap_user_function(func)
Expand Down
13 changes: 6 additions & 7 deletions backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,16 +992,17 @@ async def get_shared_thread(
"""Get a shared thread (read-only for everyone).

This endpoint is separate from the resume endpoint and does not require the caller
to be the author of the thread. It only returns the thread if its metadata
contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence.
to be the author of the thread. Access is authorized by the app-defined
on_shared_thread_view callback — if the callback returns True the thread is
returned; otherwise a 404 is raised to avoid leaking existence.
"""

data_layer = get_data_layer()

if not data_layer:
raise HTTPException(status_code=400, detail="Data persistence is not enabled")

# No auth required: allow anonymous access to shared threads
# Retrieve thread from data layer; authorization is handled by the on_shared_thread_view callback below
thread = await data_layer.get_thread(thread_id)

if not thread:
Expand All @@ -1025,10 +1026,8 @@ async def get_shared_thread(
except Exception:
user_can_view = False

is_shared = bool(metadata.get("is_shared"))

# Proceed only raise an error if both conditions are False.
if (not user_can_view) and (not is_shared):
# Proceed only raise an error if user_can_view return False or exception
if not user_can_view:
raise HTTPException(status_code=404, detail="Thread not found")
Comment on lines 1025 to 1030

metadata.pop("chat_profile", None)
Expand Down
61 changes: 61 additions & 0 deletions backend/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,3 +1152,64 @@ def test_health_check(test_client: TestClient):
response = test_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}


@pytest.mark.parametrize(("is_shared", "on_shared_thread_view_result", "allowed"), [
(True, True, True),
(True, False, False),
(True, ValueError("error"), False),
(False, True, True),
(False, False, False),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this removes the metadata["is_shared"] feature, right?
(Please forgive me if I don't fully follow, this codebase is a ... complex beast.)

If we remove this, should it still be in the tests?

])
def test_get_shared_thread_access(
test_client: TestClient,
test_config: ChainlitConfig,
is_shared: bool,
on_shared_thread_view_result: bool | Exception,
allowed: bool,
):
"""Check if shared thread access is allowed based on is_shared and on_shared_thread_view result."""
import chainlit.data as data_mod
from chainlit.server import app as _app, get_current_user as _get_current_user

viewer = PersistedUser(
id="viewer1",
createdAt=datetime.datetime.now().isoformat(),
identifier="viewer",
)
_app.dependency_overrides[_get_current_user] = lambda: viewer

dl = AsyncMock()
dl.get_thread.return_value = {
"id": "shared-thread-1",
"name": "Shared Thread",
"userIdentifier": "author",
"metadata": {"is_shared": is_shared, "chat_profile": "pro"},
}
dl.get_thread_author.return_value = "author"
dl.build_debug_url.return_value = ""

data_mod._data_layer = dl
data_mod._data_layer_initialized = True

async def deny_cb(thread, user):
if isinstance(on_shared_thread_view_result, Exception):
raise on_shared_thread_view_result
return on_shared_thread_view_result

test_config.code.on_shared_thread_view = deny_cb

r = test_client.get("/project/share/shared-thread-1")

Comment on lines +1201 to +1206
if allowed:
assert r.status_code == 200
assert r.json()["id"] == "shared-thread-1"
else:
assert r.status_code == 404
assert r.json() == {"detail": "Thread not found"}

# Cleanup
del _app.dependency_overrides[_get_current_user]
data_mod._data_layer = None
data_mod._data_layer_initialized = False
test_config.code.on_shared_thread_view = None
Comment on lines +1183 to +1218
Loading