Skip to content

Commit af5238f

Browse files
committed
feat: add session sharing support
1 parent b755df1 commit af5238f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1483
-387
lines changed

backend/app/application/errors/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ def __init__(self, msg: str = "Internal server error"):
3232

3333

3434
class UnauthorizedError(AppException):
35-
def __init__(self, msg: str = "Unauthorized"):
35+
def __init__(self, msg: str = "Authentication required"):
3636
super().__init__(code=401, msg=msg, status_code=401)

backend/app/application/services/agent_service.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,13 @@ async def chat(
9999
yield event
100100
logger.info(f"Chat with session {session_id} completed")
101101

102-
async def get_session(self, session_id: str, user_id: str) -> Optional[Session]:
102+
async def get_session(self, session_id: str, user_id: Optional[str] = None) -> Optional[Session]:
103103
"""Get a session by ID, ensuring it belongs to the user"""
104104
logger.info(f"Getting session {session_id} for user {user_id}")
105-
session = await self._session_repository.find_by_id_and_user_id(session_id, user_id)
105+
if not user_id:
106+
session = await self._session_repository.find_by_id(session_id)
107+
else:
108+
session = await self._session_repository.find_by_id_and_user_id(session_id, user_id)
106109
if not session:
107110
logger.error(f"Session {session_id} not found for user {user_id}")
108111
return session
@@ -209,12 +212,60 @@ async def file_view(self, session_id: str, file_path: str, user_id: str) -> File
209212
return FileViewResponse(**result.data)
210213
else:
211214
raise RuntimeError(f"Failed to read file: {result.message}")
215+
216+
async def is_session_shared(self, session_id: str) -> bool:
217+
"""Check if a session is shared"""
218+
logger.info(f"Checking if session {session_id} is shared")
219+
session = await self._session_repository.find_by_id(session_id)
220+
if not session:
221+
logger.error(f"Session {session_id} not found")
222+
raise RuntimeError("Session not found")
223+
return session.is_shared
212224

213-
async def get_session_files(self, session_id: str, user_id: str) -> List[FileInfo]:
225+
async def get_session_files(self, session_id: str, user_id: Optional[str] = None) -> List[FileInfo]:
214226
"""Get files for a session, ensuring it belongs to the user"""
215227
logger.info(f"Getting files for session {session_id} for user {user_id}")
228+
session = await self.get_session(session_id, user_id)
229+
return session.files
230+
231+
async def get_shared_session_files(self, session_id: str) -> List[FileInfo]:
232+
"""Get files for a shared session"""
233+
logger.info(f"Getting files for shared session {session_id}")
234+
session = await self._session_repository.find_by_id(session_id)
235+
if not session or not session.is_shared:
236+
logger.error(f"Shared session {session_id} not found or not shared")
237+
raise RuntimeError("Session not found")
238+
return session.files
239+
240+
async def share_session(self, session_id: str, user_id: str) -> None:
241+
"""Share a session, ensuring it belongs to the user"""
242+
logger.info(f"Sharing session {session_id} for user {user_id}")
243+
# First verify the session belongs to the user
216244
session = await self._session_repository.find_by_id_and_user_id(session_id, user_id)
217245
if not session:
218246
logger.error(f"Session {session_id} not found for user {user_id}")
219247
raise RuntimeError("Session not found")
220-
return session.files
248+
249+
await self._session_repository.update_shared_status(session_id, True)
250+
logger.info(f"Session {session_id} shared successfully")
251+
252+
async def unshare_session(self, session_id: str, user_id: str) -> None:
253+
"""Unshare a session, ensuring it belongs to the user"""
254+
logger.info(f"Unsharing session {session_id} for user {user_id}")
255+
# First verify the session belongs to the user
256+
session = await self._session_repository.find_by_id_and_user_id(session_id, user_id)
257+
if not session:
258+
logger.error(f"Session {session_id} not found for user {user_id}")
259+
raise RuntimeError("Session not found")
260+
261+
await self._session_repository.update_shared_status(session_id, False)
262+
logger.info(f"Session {session_id} unshared successfully")
263+
264+
async def get_shared_session(self, session_id: str) -> Optional[Session]:
265+
"""Get a shared session by ID (no user authentication required)"""
266+
logger.info(f"Getting shared session {session_id}")
267+
session = await self._session_repository.find_by_id(session_id)
268+
if not session or not session.is_shared:
269+
logger.error(f"Shared session {session_id} not found or not shared")
270+
return None
271+
return session

backend/app/application/services/file_service.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
import logging
33
from app.domain.external.file import FileStorage
44
from app.domain.models.file import FileInfo
5+
from app.application.services.token_service import TokenService
56

67
# Set up logger
78
logger = logging.getLogger(__name__)
89

910
class FileService:
10-
def __init__(self, file_storage: Optional[FileStorage] = None):
11+
def __init__(self, file_storage: Optional[FileStorage] = None, token_service: Optional[TokenService] = None):
1112
self._file_storage = file_storage
13+
self._token_service = token_service
1214

1315
async def upload_file(self, file_data: BinaryIO, filename: str, user_id: str, content_type: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> FileInfo:
1416
"""Upload file"""
@@ -58,7 +60,7 @@ async def delete_file(self, file_id: str, user_id: str) -> bool:
5860
logger.error(f"Failed to delete file {file_id} for user {user_id}: {str(e)}")
5961
raise
6062

61-
async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]:
63+
async def get_file_info(self, file_id: str, user_id: Optional[str] = None) -> Optional[FileInfo]:
6264
"""Get file information"""
6365
logger.info(f"Get file info request: file_id={file_id}, user_id={user_id}")
6466
if not self._file_storage:
@@ -75,3 +77,44 @@ async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]:
7577
except Exception as e:
7678
logger.error(f"Failed to get file info {file_id} for user {user_id}: {str(e)}")
7779
raise
80+
81+
async def enrich_with_file_url(self, file_info: FileInfo) -> FileInfo:
82+
"""Enrich file information with file URL"""
83+
logger.info(f"Enrich file info request: file_info={file_info}")
84+
85+
try:
86+
signed_url = await self.create_signed_url(file_info.file_id, file_info.user_id)
87+
file_info.file_url = signed_url
88+
return file_info
89+
except Exception as e:
90+
logger.error(f"Failed to enrich file info {file_info.file_id} with file URL: {str(e)}")
91+
raise
92+
93+
async def create_signed_url(self, file_id: str, user_id: Optional[str] = None, expire_minutes: int = 30) -> str:
94+
"""Create signed URL for file download"""
95+
logger.info(f"Create signed URL request: file_id={file_id}, user_id={user_id}, expire_minutes={expire_minutes}")
96+
97+
if not self._token_service:
98+
logger.error("Token service not available")
99+
raise RuntimeError("Token service not available")
100+
101+
# Validate expiration time (max 15 minutes)
102+
if expire_minutes > 30:
103+
expire_minutes = 30
104+
105+
# Check if file exists and user has access
106+
file_info = await self.get_file_info(file_id, user_id)
107+
if not file_info:
108+
logger.warning(f"File not found or access denied for signed URL: file_id={file_id}, user_id={user_id}")
109+
raise FileNotFoundError("File not found")
110+
111+
# Create signed URL for file download
112+
base_url = f"/api/v1/files/{file_id}"
113+
signed_url = self._token_service.create_signed_url(
114+
base_url=base_url,
115+
expire_minutes=expire_minutes
116+
)
117+
118+
logger.info(f"Created signed URL for file download for user {user_id}, file {file_id}")
119+
120+
return signed_url

backend/app/domain/external/file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def delete_file(
6363
async def get_file_info(
6464
self,
6565
file_id: str,
66-
user_id: str
66+
user_id: Optional[str] = None
6767
) -> Optional[FileInfo]:
6868
"""Get file metadata from storage
6969

backend/app/domain/models/file.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ class FileInfo(BaseModel):
1212
upload_date: Optional[datetime] = None
1313
metadata: Optional[Dict[str, Any]] = None
1414
user_id: Optional[str] = None
15+
file_url: Optional[str] = None

backend/app/domain/models/session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class Session(BaseModel):
3232
events: List[AgentEvent] = []
3333
files: List[FileInfo] = []
3434
status: SessionStatus = SessionStatus.PENDING
35+
is_shared: bool = False # Whether this session is shared publicly
3536

3637
def get_last_plan(self) -> Optional[Plan]:
3738
"""Get the last plan from the events"""

backend/app/domain/repositories/session_repository.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ async def decrement_unread_message_count(self, session_id: str) -> None:
6363
"""Decrement the unread message count of a session"""
6464
...
6565

66+
async def update_shared_status(self, session_id: str, is_shared: bool) -> None:
67+
"""Update the shared status of a session"""
68+
...
69+
6670
async def delete(self, session_id: str) -> None:
6771
"""Delete a session"""
6872
...

backend/app/infrastructure/external/file/gridfsfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ async def delete_file(self, file_id: str, user_id: str) -> bool:
177177
logger.error(f"Failed to delete file {file_id} for user {user_id}: {str(e)}")
178178
return False
179179

180-
async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]:
180+
async def get_file_info(self, file_id: str, user_id: Optional[str] = None) -> Optional[FileInfo]:
181181
"""Get file information"""
182182
try:
183183
files_collection = self._get_files_collection()
@@ -195,7 +195,7 @@ async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]:
195195

196196
# Check if file belongs to the user
197197
file_user_id = file_info.get('metadata', {}).get('user_id')
198-
if file_user_id != user_id:
198+
if user_id is not None and file_user_id != user_id:
199199
logger.warning(f"Access denied: file {file_id} does not belong to user {user_id}")
200200
return None
201201

backend/app/infrastructure/models/documents.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class SessionDocument(BaseDocument[Session], id_field="session_id", domain_model
9696
events: List[AgentEvent]
9797
status: SessionStatus
9898
files: List[FileInfo] = []
99+
is_shared: Optional[bool] = False
99100
class Settings:
100101
name = "sessions"
101102
indexes = [

backend/app/infrastructure/repositories/mongo_session_repository.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,13 @@ async def decrement_unread_message_count(self, session_id: str) -> None:
167167
if not result:
168168
raise ValueError(f"Session {session_id} not found")
169169

170+
async def update_shared_status(self, session_id: str, is_shared: bool) -> None:
171+
"""Update the shared status of a session"""
172+
result = await SessionDocument.find_one(
173+
SessionDocument.session_id == session_id
174+
).update(
175+
{"$set": {"is_shared": is_shared, "updated_at": datetime.now(UTC)}}
176+
)
177+
if not result:
178+
raise ValueError(f"Session {session_id} not found")
179+

0 commit comments

Comments
 (0)