diff --git a/backend/app/application/errors/exceptions.py b/backend/app/application/errors/exceptions.py index b833be2b..2a68071e 100644 --- a/backend/app/application/errors/exceptions.py +++ b/backend/app/application/errors/exceptions.py @@ -32,5 +32,5 @@ def __init__(self, msg: str = "Internal server error"): class UnauthorizedError(AppException): - def __init__(self, msg: str = "Unauthorized"): + def __init__(self, msg: str = "Authentication required"): super().__init__(code=401, msg=msg, status_code=401) \ No newline at end of file diff --git a/backend/app/application/services/agent_service.py b/backend/app/application/services/agent_service.py index 4395ccc1..ccfdef74 100644 --- a/backend/app/application/services/agent_service.py +++ b/backend/app/application/services/agent_service.py @@ -99,10 +99,13 @@ async def chat( yield event logger.info(f"Chat with session {session_id} completed") - async def get_session(self, session_id: str, user_id: str) -> Optional[Session]: + async def get_session(self, session_id: str, user_id: Optional[str] = None) -> Optional[Session]: """Get a session by ID, ensuring it belongs to the user""" logger.info(f"Getting session {session_id} for user {user_id}") - session = await self._session_repository.find_by_id_and_user_id(session_id, user_id) + if not user_id: + session = await self._session_repository.find_by_id(session_id) + else: + session = await self._session_repository.find_by_id_and_user_id(session_id, user_id) if not session: logger.error(f"Session {session_id} not found for user {user_id}") return session @@ -209,12 +212,60 @@ async def file_view(self, session_id: str, file_path: str, user_id: str) -> File return FileViewResponse(**result.data) else: raise RuntimeError(f"Failed to read file: {result.message}") + + async def is_session_shared(self, session_id: str) -> bool: + """Check if a session is shared""" + logger.info(f"Checking if session {session_id} is shared") + session = await self._session_repository.find_by_id(session_id) + if not session: + logger.error(f"Session {session_id} not found") + raise RuntimeError("Session not found") + return session.is_shared - async def get_session_files(self, session_id: str, user_id: str) -> List[FileInfo]: + async def get_session_files(self, session_id: str, user_id: Optional[str] = None) -> List[FileInfo]: """Get files for a session, ensuring it belongs to the user""" logger.info(f"Getting files for session {session_id} for user {user_id}") + session = await self.get_session(session_id, user_id) + return session.files + + async def get_shared_session_files(self, session_id: str) -> List[FileInfo]: + """Get files for a shared session""" + logger.info(f"Getting files for shared session {session_id}") + session = await self._session_repository.find_by_id(session_id) + if not session or not session.is_shared: + logger.error(f"Shared session {session_id} not found or not shared") + raise RuntimeError("Session not found") + return session.files + + async def share_session(self, session_id: str, user_id: str) -> None: + """Share a session, ensuring it belongs to the user""" + logger.info(f"Sharing session {session_id} for user {user_id}") + # First verify the session belongs to the user session = await self._session_repository.find_by_id_and_user_id(session_id, user_id) if not session: logger.error(f"Session {session_id} not found for user {user_id}") raise RuntimeError("Session not found") - return session.files \ No newline at end of file + + await self._session_repository.update_shared_status(session_id, True) + logger.info(f"Session {session_id} shared successfully") + + async def unshare_session(self, session_id: str, user_id: str) -> None: + """Unshare a session, ensuring it belongs to the user""" + logger.info(f"Unsharing session {session_id} for user {user_id}") + # First verify the session belongs to the user + session = await self._session_repository.find_by_id_and_user_id(session_id, user_id) + if not session: + logger.error(f"Session {session_id} not found for user {user_id}") + raise RuntimeError("Session not found") + + await self._session_repository.update_shared_status(session_id, False) + logger.info(f"Session {session_id} unshared successfully") + + async def get_shared_session(self, session_id: str) -> Optional[Session]: + """Get a shared session by ID (no user authentication required)""" + logger.info(f"Getting shared session {session_id}") + session = await self._session_repository.find_by_id(session_id) + if not session or not session.is_shared: + logger.error(f"Shared session {session_id} not found or not shared") + return None + return session \ No newline at end of file diff --git a/backend/app/application/services/file_service.py b/backend/app/application/services/file_service.py index febe8361..670dc99f 100644 --- a/backend/app/application/services/file_service.py +++ b/backend/app/application/services/file_service.py @@ -2,13 +2,15 @@ import logging from app.domain.external.file import FileStorage from app.domain.models.file import FileInfo +from app.application.services.token_service import TokenService # Set up logger logger = logging.getLogger(__name__) class FileService: - def __init__(self, file_storage: Optional[FileStorage] = None): + def __init__(self, file_storage: Optional[FileStorage] = None, token_service: Optional[TokenService] = None): self._file_storage = file_storage + self._token_service = token_service 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: """Upload file""" @@ -58,7 +60,7 @@ async def delete_file(self, file_id: str, user_id: str) -> bool: logger.error(f"Failed to delete file {file_id} for user {user_id}: {str(e)}") raise - async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]: + async def get_file_info(self, file_id: str, user_id: Optional[str] = None) -> Optional[FileInfo]: """Get file information""" logger.info(f"Get file info request: file_id={file_id}, user_id={user_id}") if not self._file_storage: @@ -75,3 +77,44 @@ async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]: except Exception as e: logger.error(f"Failed to get file info {file_id} for user {user_id}: {str(e)}") raise + + async def enrich_with_file_url(self, file_info: FileInfo) -> FileInfo: + """Enrich file information with file URL""" + logger.info(f"Enrich file info request: file_info={file_info}") + + try: + signed_url = await self.create_signed_url(file_info.file_id, file_info.user_id) + file_info.file_url = signed_url + return file_info + except Exception as e: + logger.error(f"Failed to enrich file info {file_info.file_id} with file URL: {str(e)}") + raise + + async def create_signed_url(self, file_id: str, user_id: Optional[str] = None, expire_minutes: int = 30) -> str: + """Create signed URL for file download""" + logger.info(f"Create signed URL request: file_id={file_id}, user_id={user_id}, expire_minutes={expire_minutes}") + + if not self._token_service: + logger.error("Token service not available") + raise RuntimeError("Token service not available") + + # Validate expiration time (max 15 minutes) + if expire_minutes > 30: + expire_minutes = 30 + + # Check if file exists and user has access + file_info = await self.get_file_info(file_id, user_id) + if not file_info: + logger.warning(f"File not found or access denied for signed URL: file_id={file_id}, user_id={user_id}") + raise FileNotFoundError("File not found") + + # Create signed URL for file download + base_url = f"/api/v1/files/{file_id}" + signed_url = self._token_service.create_signed_url( + base_url=base_url, + expire_minutes=expire_minutes + ) + + logger.info(f"Created signed URL for file download for user {user_id}, file {file_id}") + + return signed_url diff --git a/backend/app/domain/external/file.py b/backend/app/domain/external/file.py index 1752d2e8..88ade25a 100644 --- a/backend/app/domain/external/file.py +++ b/backend/app/domain/external/file.py @@ -63,7 +63,7 @@ async def delete_file( async def get_file_info( self, file_id: str, - user_id: str + user_id: Optional[str] = None ) -> Optional[FileInfo]: """Get file metadata from storage diff --git a/backend/app/domain/models/file.py b/backend/app/domain/models/file.py index a4574aae..d697f3b2 100644 --- a/backend/app/domain/models/file.py +++ b/backend/app/domain/models/file.py @@ -12,3 +12,4 @@ class FileInfo(BaseModel): upload_date: Optional[datetime] = None metadata: Optional[Dict[str, Any]] = None user_id: Optional[str] = None + file_url: Optional[str] = None diff --git a/backend/app/domain/models/session.py b/backend/app/domain/models/session.py index 331b0f1a..6da5b07b 100644 --- a/backend/app/domain/models/session.py +++ b/backend/app/domain/models/session.py @@ -32,6 +32,7 @@ class Session(BaseModel): events: List[AgentEvent] = [] files: List[FileInfo] = [] status: SessionStatus = SessionStatus.PENDING + is_shared: bool = False # Whether this session is shared publicly def get_last_plan(self) -> Optional[Plan]: """Get the last plan from the events""" diff --git a/backend/app/domain/repositories/session_repository.py b/backend/app/domain/repositories/session_repository.py index 7ce999fb..255d3bef 100644 --- a/backend/app/domain/repositories/session_repository.py +++ b/backend/app/domain/repositories/session_repository.py @@ -63,6 +63,10 @@ async def decrement_unread_message_count(self, session_id: str) -> None: """Decrement the unread message count of a session""" ... + async def update_shared_status(self, session_id: str, is_shared: bool) -> None: + """Update the shared status of a session""" + ... + async def delete(self, session_id: str) -> None: """Delete a session""" ... diff --git a/backend/app/infrastructure/external/file/gridfsfile.py b/backend/app/infrastructure/external/file/gridfsfile.py index eeefa389..efc52b7d 100644 --- a/backend/app/infrastructure/external/file/gridfsfile.py +++ b/backend/app/infrastructure/external/file/gridfsfile.py @@ -177,7 +177,7 @@ async def delete_file(self, file_id: str, user_id: str) -> bool: logger.error(f"Failed to delete file {file_id} for user {user_id}: {str(e)}") return False - async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]: + async def get_file_info(self, file_id: str, user_id: Optional[str] = None) -> Optional[FileInfo]: """Get file information""" try: files_collection = self._get_files_collection() @@ -195,7 +195,7 @@ async def get_file_info(self, file_id: str, user_id: str) -> Optional[FileInfo]: # Check if file belongs to the user file_user_id = file_info.get('metadata', {}).get('user_id') - if file_user_id != user_id: + if user_id is not None and file_user_id != user_id: logger.warning(f"Access denied: file {file_id} does not belong to user {user_id}") return None diff --git a/backend/app/infrastructure/models/documents.py b/backend/app/infrastructure/models/documents.py index 49c0dd4e..21e8855c 100644 --- a/backend/app/infrastructure/models/documents.py +++ b/backend/app/infrastructure/models/documents.py @@ -96,6 +96,7 @@ class SessionDocument(BaseDocument[Session], id_field="session_id", domain_model events: List[AgentEvent] status: SessionStatus files: List[FileInfo] = [] + is_shared: Optional[bool] = False class Settings: name = "sessions" indexes = [ diff --git a/backend/app/infrastructure/repositories/mongo_session_repository.py b/backend/app/infrastructure/repositories/mongo_session_repository.py index c0226135..5a163b6b 100644 --- a/backend/app/infrastructure/repositories/mongo_session_repository.py +++ b/backend/app/infrastructure/repositories/mongo_session_repository.py @@ -167,3 +167,13 @@ async def decrement_unread_message_count(self, session_id: str) -> None: if not result: raise ValueError(f"Session {session_id} not found") + async def update_shared_status(self, session_id: str, is_shared: bool) -> None: + """Update the shared status of a session""" + result = await SessionDocument.find_one( + SessionDocument.session_id == session_id + ).update( + {"$set": {"is_shared": is_shared, "updated_at": datetime.now(UTC)}} + ) + if not result: + raise ValueError(f"Session {session_id} not found") + diff --git a/backend/app/interfaces/api/auth_routes.py b/backend/app/interfaces/api/auth_routes.py index 67c5fdf6..b6286df9 100644 --- a/backend/app/interfaces/api/auth_routes.py +++ b/backend/app/interfaces/api/auth_routes.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import logging from app.application.services.auth_service import AuthService @@ -186,21 +187,16 @@ async def refresh_token( @router.post("/logout", response_model=APIResponse[dict]) async def logout( - request: Request, + current_user: User = Depends(get_current_user), + bearer_credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), auth_service: AuthService = Depends(get_auth_service) ) -> APIResponse[dict]: """User logout endpoint""" if get_settings().auth_provider == "none": raise BadRequestError("Logout is not allowed") - # Extract token from Authorization header - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - raise UnauthorizedError("Authentication required") - - token = auth_header.split(" ")[1] # Revoke token - await auth_service.logout(token) + await auth_service.logout(bearer_credentials.credentials) return APIResponse.success({}) diff --git a/backend/app/interfaces/api/file_routes.py b/backend/app/interfaces/api/file_routes.py index 38237446..330514b2 100644 --- a/backend/app/interfaces/api/file_routes.py +++ b/backend/app/interfaces/api/file_routes.py @@ -1,26 +1,25 @@ -from fastapi import APIRouter, Depends, UploadFile, File, Query +from fastapi import APIRouter, Depends, UploadFile, File from fastapi.responses import StreamingResponse import logging from app.application.services.file_service import FileService -from app.application.services.token_service import TokenService -from app.application.errors.exceptions import NotFoundError, UnauthorizedError -from app.interfaces.dependencies import get_file_service, get_current_user, get_token_service, get_optional_current_user +from app.application.errors.exceptions import NotFoundError +from app.interfaces.dependencies import get_file_service, get_current_user, get_optional_current_user, verify_signature from app.domain.models.user import User from app.interfaces.schemas.base import APIResponse -from app.interfaces.schemas.file import FileUploadResponse, FileInfoResponse +from app.interfaces.schemas.file import FileInfoResponse from app.interfaces.schemas.resource import AccessTokenRequest, SignedUrlResponse logger = logging.getLogger(__name__) router = APIRouter(prefix="/files", tags=["files"]) -@router.post("", response_model=APIResponse[FileUploadResponse]) +@router.post("", response_model=APIResponse[FileInfoResponse]) async def upload_file( file: UploadFile = File(...), file_service: FileService = Depends(get_file_service), current_user: User = Depends(get_current_user) -) -> APIResponse[FileUploadResponse]: +) -> APIResponse[FileInfoResponse]: """Upload file""" # Upload file result = await file_service.upload_file( @@ -30,15 +29,40 @@ async def upload_file( content_type=file.content_type ) - return APIResponse.success(FileUploadResponse( - file_id=result.file_id, - filename=result.filename, - size=result.size, - upload_date=result.upload_date.isoformat(), - message="File uploaded successfully" - )) + return APIResponse.success(FileInfoResponse.from_file_info(result)) @router.get("/{file_id}") +async def download_file_with_signature( + file_id: str, + file_service: FileService = Depends(get_file_service), + signature: str = Depends(verify_signature), +): + """Download file with optional access token""" + + # Download file (authentication is handled by middleware for non-token requests) + try: + file_data, file_info = await file_service.download_file(file_id) + except FileNotFoundError: + raise NotFoundError("File not found") + except PermissionError: + raise NotFoundError("File not found") # Don't reveal if file exists but user has no access + + # Encode filename properly for Content-Disposition header + # Use URL encoding for non-ASCII characters to ensure latin-1 compatibility + import urllib.parse + encoded_filename = urllib.parse.quote(file_info.filename, safe='') + + headers = { + 'Content-Disposition': f'attachment; filename*=UTF-8\'\'{encoded_filename}' + } + + return StreamingResponse( + file_data, + media_type=file_info.content_type or 'application/octet-stream', + headers=headers + ) + +@router.get("/{file_id}/download") async def download_file( file_id: str, file_service: FileService = Depends(get_file_service), @@ -92,14 +116,7 @@ async def get_file_info( if not file_info: raise NotFoundError("File not found") - return APIResponse.success(FileInfoResponse( - file_id=file_info.file_id, - filename=file_info.filename, - content_type=file_info.content_type, - size=file_info.size, - upload_date=file_info.upload_date.isoformat(), - metadata=file_info.metadata - )) + return APIResponse.success(FileInfoResponse.from_file_info(file_info)) @router.post("/{file_id}/signed-url", response_model=APIResponse[SignedUrlResponse]) @@ -107,8 +124,7 @@ async def create_file_signed_url( file_id: str, request_data: AccessTokenRequest, current_user: User = Depends(get_current_user), - file_service: FileService = Depends(get_file_service), - token_service: TokenService = Depends(get_token_service) + file_service: FileService = Depends(get_file_service) ) -> APIResponse[SignedUrlResponse]: """Generate signed URL for file download @@ -116,26 +132,17 @@ async def create_file_signed_url( a specific file without requiring authentication headers. """ - # Validate expiration time (max 15 minutes) - expire_minutes = request_data.expire_minutes - if expire_minutes > 15: - expire_minutes = 15 - - # Check if file exists and user has access - file_info = await file_service.get_file_info(file_id, current_user.id) - if not file_info: + try: + # Create signed URL using file service + signed_url = await file_service.create_signed_url( + file_id=file_id, + user_id=current_user.id, + expire_minutes=request_data.expire_minutes + ) + + return APIResponse.success(SignedUrlResponse( + signed_url=signed_url, + expires_in=request_data.expire_minutes * 60, + )) + except FileNotFoundError: raise NotFoundError("File not found") - - # Create signed URL for file download - base_url = f"/api/v1/files/{file_id}" - signed_url = token_service.create_signed_url( - base_url=base_url, - expire_minutes=expire_minutes - ) - - logger.info(f"Created signed URL for file download for user {current_user.id}, file {file_id}") - - return APIResponse.success(SignedUrlResponse( - signed_url=signed_url, - expires_in=expire_minutes * 60, - )) diff --git a/backend/app/interfaces/api/session_routes.py b/backend/app/interfaces/api/session_routes.py index bca2de62..c28b33b6 100644 --- a/backend/app/interfaces/api/session_routes.py +++ b/backend/app/interfaces/api/session_routes.py @@ -1,20 +1,22 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Query from sse_starlette.sse import EventSourceResponse -from typing import AsyncGenerator, List +from typing import AsyncGenerator, List, Optional from sse_starlette.event import ServerSentEvent from datetime import datetime import asyncio import websockets import logging +from app.interfaces.dependencies import get_file_service from app.application.services.agent_service import AgentService from app.application.services.token_service import TokenService from app.application.errors.exceptions import NotFoundError, UnauthorizedError -from app.interfaces.dependencies import get_agent_service, get_current_user, get_token_service +from app.interfaces.dependencies import get_agent_service, get_current_user, get_optional_current_user, get_token_service, verify_signature_websocket from app.interfaces.schemas.base import APIResponse from app.interfaces.schemas.session import ( ChatRequest, ShellViewRequest, CreateSessionResponse, GetSessionResponse, - ListSessionItem, ListSessionResponse, ShellViewResponse + ListSessionItem, ListSessionResponse, ShellViewResponse, + ShareSessionResponse, SharedSessionResponse ) from app.interfaces.schemas.file import FileViewRequest, FileViewResponse from app.interfaces.schemas.resource import AccessTokenRequest, SignedUrlResponse @@ -52,7 +54,8 @@ async def get_session( session_id=session.id, title=session.title, status=session.status, - events=EventMapper.events_to_sse_events(session.events) + events=await EventMapper.events_to_sse_events(session.events), + is_shared=session.is_shared )) @router.delete("/{session_id}", response_model=APIResponse[None]) @@ -95,7 +98,8 @@ async def get_all_sessions( status=session.status, unread_message_count=session.unread_message_count, latest_message=session.latest_message, - latest_message_at=int(session.latest_message_at.timestamp()) if session.latest_message_at else None + latest_message_at=int(session.latest_message_at.timestamp()) if session.latest_message_at else None, + is_shared=session.is_shared ) for session in sessions ] return APIResponse.success(ListSessionResponse(sessions=session_items)) @@ -115,7 +119,8 @@ async def event_generator() -> AsyncGenerator[ServerSentEvent, None]: status=session.status, unread_message_count=session.unread_message_count, latest_message=session.latest_message, - latest_message_at=int(session.latest_message_at.timestamp()) if session.latest_message_at else None + latest_message_at=int(session.latest_message_at.timestamp()) if session.latest_message_at else None, + is_shared=session.is_shared ) for session in sessions ] yield ServerSentEvent( @@ -142,7 +147,7 @@ async def event_generator() -> AsyncGenerator[ServerSentEvent, None]: attachments=request.attachments ): logger.debug(f"Received event from chat: {event}") - sse_event = EventMapper.event_to_sse_event(event) + sse_event = await EventMapper.event_to_sse_event(event) logger.debug(f"Received event: {sse_event}") if sse_event: yield ServerSentEvent( @@ -198,31 +203,22 @@ async def view_file( async def vnc_websocket( websocket: WebSocket, session_id: str, - signature: str = Query(None), - agent_service: AgentService = Depends(get_agent_service), - token_service: TokenService = Depends(get_token_service) + signature: str = Depends(verify_signature_websocket), + agent_service: AgentService = Depends(get_agent_service) ) -> None: """VNC WebSocket endpoint (binary mode) Establishes a connection with the VNC WebSocket service in the sandbox environment and forwards data bidirectionally - Supports authentication via both URL token parameter or Authorization header for backward compatibility + Supports authentication via signed URL with signature verification Args: websocket: WebSocket connection session_id: Session ID + signature: Verified signature from dependency injection """ await websocket.accept(subprotocol="binary") logger.info(f"Accepted WebSocket connection for session {session_id}") - - if not signature: - logger.error(f"Missing signature: {websocket.url}") - await websocket.close(code=1011, reason="Missing signature") - return - if not token_service.verify_signed_url(str(websocket.url)): - logger.error(f"Invalid signature: {websocket.url}") - await websocket.close(code=1011, reason="Invalid signature") - return try: # Get sandbox environment address with user validation @@ -282,10 +278,12 @@ async def forward_from_sandbox(): @router.get("/{session_id}/files") async def get_session_files( session_id: str, - current_user: User = Depends(get_current_user), + current_user: Optional[User] = Depends(get_optional_current_user), agent_service: AgentService = Depends(get_agent_service) ) -> APIResponse[List[FileInfo]]: - files = await agent_service.get_session_files(session_id, current_user.id) + if not current_user and not await agent_service.is_session_shared(session_id): + raise UnauthorizedError() + files = await agent_service.get_session_files(session_id, current_user.id if current_user else None) return APIResponse.success(files) @@ -325,4 +323,72 @@ async def create_vnc_signed_url( return APIResponse.success(SignedUrlResponse( signed_url=signed_url, expires_in=expire_minutes * 60, + )) + + +@router.post("/{session_id}/share", response_model=APIResponse[ShareSessionResponse]) +async def share_session( + session_id: str, + current_user: User = Depends(get_current_user), + agent_service: AgentService = Depends(get_agent_service) +) -> APIResponse[ShareSessionResponse]: + """Share a session to make it publicly accessible + + This endpoint marks a session as shared, allowing it to be accessed + without authentication using the shared session endpoint. + """ + await agent_service.share_session(session_id, current_user.id) + return APIResponse.success(ShareSessionResponse( + session_id=session_id, + is_shared=True + )) + +@router.get("/{session_id}/share/files") +async def get_shared_session_files( + session_id: str, + agent_service: AgentService = Depends(get_agent_service) +) -> APIResponse[List[FileInfo]]: + files = await agent_service.get_shared_session_files(session_id) + for file in files: + await get_file_service().enrich_with_file_url(file) + return APIResponse.success(files) + + +@router.delete("/{session_id}/share", response_model=APIResponse[ShareSessionResponse]) +async def unshare_session( + session_id: str, + current_user: User = Depends(get_current_user), + agent_service: AgentService = Depends(get_agent_service) +) -> APIResponse[ShareSessionResponse]: + """Unshare a session to make it private again + + This endpoint marks a session as not shared, removing public access. + """ + await agent_service.unshare_session(session_id, current_user.id) + return APIResponse.success(ShareSessionResponse( + session_id=session_id, + is_shared=False + )) + + +@router.get("/shared/{session_id}", response_model=APIResponse[SharedSessionResponse]) +async def get_shared_session( + session_id: str, + agent_service: AgentService = Depends(get_agent_service) +) -> APIResponse[SharedSessionResponse]: + """Get a shared session without authentication + + This endpoint allows public access to sessions that have been marked as shared. + No authentication is required, but the session must be explicitly shared. + """ + session = await agent_service.get_shared_session(session_id) + if not session: + raise NotFoundError("Shared session not found") + + return APIResponse.success(SharedSessionResponse( + session_id=session.id, + title=session.title, + status=session.status, + events=await EventMapper.events_to_sse_events(session.events), + is_shared=session.is_shared )) \ No newline at end of file diff --git a/backend/app/interfaces/dependencies.py b/backend/app/interfaces/dependencies.py index fec51a68..ee6f5fea 100644 --- a/backend/app/interfaces/dependencies.py +++ b/backend/app/interfaces/dependencies.py @@ -1,11 +1,14 @@ -from typing import Optional +from typing import Optional, Union import logging from functools import lru_cache -from fastapi import Request +from fastapi import Request, Header, HTTPException, status, Depends, Query +from starlette.websockets import WebSocket +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from app.infrastructure.external.file.gridfsfile import get_file_storage from app.infrastructure.external.search import get_search_engine -from app.domain.models.user import User +from app.domain.models.user import User, UserRole from app.application.errors.exceptions import UnauthorizedError +from app.core.config import get_settings # Import all required services from app.application.services.agent_service import AgentService @@ -29,6 +32,9 @@ # Configure logging logger = logging.getLogger(__name__) +# Security scheme - Bearer Token only +security_bearer = HTTPBearer(auto_error=False) + @lru_cache() def get_agent_service() -> AgentService: """ @@ -70,15 +76,17 @@ def get_file_service() -> FileService: Get file service instance with required dependencies This function creates and returns a FileService instance with - the necessary file storage dependency. + the necessary file storage and token service dependencies. """ logger.info("Creating FileService instance") - # Get file storage dependency + # Get dependencies file_storage = get_file_storage() + token_service = get_token_service() return FileService( file_storage=file_storage, + token_service=token_service, ) @@ -116,30 +124,141 @@ def get_email_service() -> EmailService: return EmailService(cache=cache) -def get_current_user(request: Request) -> User: +async def get_current_user( + bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_bearer), + auth_service: AuthService = Depends(get_auth_service) +) -> User: """ - Get current authenticated user from request state + Get current authenticated user (required) - This function extracts the current user from the request state - that was set by the authentication middleware. + This dependency enforces authentication using Bearer Token. + If authentication fails, it raises an UnauthorizedError. """ - if not hasattr(request.state, 'user'): - raise UnauthorizedError("Authentication required") + settings = get_settings() - user = request.state.user - if not user: - raise UnauthorizedError("Invalid user session") + # If auth_provider is 'none', return anonymous user + if settings.auth_provider == "none": + return User( + id="anonymous", + fullname="anonymous", + email="anonymous@localhost", + role=UserRole.USER, + is_active=True + ) - return user + # Check if bearer token is provided + if not bearer_credentials: + raise UnauthorizedError("Authentication required") + + try: + # Verify bearer token + user = await auth_service.verify_token(bearer_credentials.credentials) + + if not user: + raise UnauthorizedError("Invalid token") + + if not user.is_active: + raise UnauthorizedError("User account is inactive") + + return user + + except Exception as e: + logger.warning(f"Authentication failed: {e}") + raise UnauthorizedError("Authentication failed") + -def get_optional_current_user(request: Request) -> Optional[User]: +async def get_optional_current_user( + bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_bearer), + auth_service: AuthService = Depends(get_auth_service) +) -> Optional[User]: """ - Get current authenticated user from request state, return None if not authenticated + Get current authenticated user (optional) - This function extracts the current user from the request state - that was set by the authentication middleware. Returns None if no user. + This dependency allows both authenticated and anonymous access. + Returns None if authentication fails or is not provided. + + Uses Bearer Token authentication. """ - if not hasattr(request.state, 'user'): + settings = get_settings() + + # If auth_provider is 'none', return anonymous user + if settings.auth_provider == "none": + return User( + id="anonymous", + fullname="anonymous", + email="anonymous@localhost", + role=UserRole.USER, + is_active=True + ) + + # If no bearer token provided, return None + if not bearer_credentials: return None - return request.state.user \ No newline at end of file + try: + # Try to verify bearer token + user = await auth_service.verify_token(bearer_credentials.credentials) + + if user and user.is_active: + return user + + except Exception as e: + logger.warning(f"Optional authentication failed: {e}") + + return None + +async def verify_signature( + request: Request, + signature: Optional[str] = Query(None), + token_service: TokenService = Depends(get_token_service) +) -> str: + return await _verify_signature(request, signature, token_service) + +async def verify_signature_websocket( + request: WebSocket, + signature: Optional[str] = Query(None), + token_service: TokenService = Depends(get_token_service) +) -> str: + return await _verify_signature(request, signature, token_service) + +async def _verify_signature( + request: Union[Request, WebSocket], + signature: Optional[str] = Query(None), + token_service: TokenService = Depends(get_token_service) +) -> str: + """ + Verify signature for signed URL access + + This dependency validates the signature parameter in the request URL. + If the signature is missing or invalid, it raises an HTTPException. + + This is designed to work with both regular HTTP endpoints and WebSocket endpoints. + For WebSocket connections, the exception will be raised before the connection is accepted, + preventing invalid connections from being established. + + Args: + request: The incoming request + signature: The signature query parameter + token_service: Token service for signature verification + + Returns: + The verified signature string + + Raises: + HTTPException: If signature is missing or invalid (status code 401) + """ + if not signature: + logger.error(f"Missing signature: {request.url}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing signature" + ) + + if not token_service.verify_signed_url(str(request.url)): + logger.error(f"Invalid signature: {request.url}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid signature" + ) + + return signature \ No newline at end of file diff --git a/backend/app/interfaces/middleware/auth.py b/backend/app/interfaces/middleware/auth.py deleted file mode 100644 index 9c7da27e..00000000 --- a/backend/app/interfaces/middleware/auth.py +++ /dev/null @@ -1,164 +0,0 @@ -from fastapi import Request, status -from fastapi.responses import JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware -from typing import Optional -import logging - -from app.core.config import get_settings -from app.interfaces.dependencies import get_token_service, get_auth_service -from app.domain.models.user import User, UserRole - -logger = logging.getLogger(__name__) - - -class AuthMiddleware(BaseHTTPMiddleware): - """Authentication middleware for API requests""" - - def __init__(self, app, excluded_paths: Optional[list] = None): - super().__init__(app) - self.settings = get_settings() - self.auth_service = get_auth_service() - self.token_service = get_token_service() - - # Default paths that don't require authentication - self.excluded_paths = excluded_paths or [ - "/api/v1/auth/login", - "/api/v1/auth/register", - "/api/v1/auth/status", - "/api/v1/auth/refresh", - "/api/v1/auth/send-verification-code", - "/api/v1/auth/reset-password", - ] - - async def dispatch(self, request: Request, call_next): - """Process authentication for each request""" - - # Skip authentication for excluded paths - if any(request.url.path.startswith(path) for path in self.excluded_paths): - return await call_next(request) - - # Check if this is a resource access request with token parameter - if self._is_resource_access_with_token(request): - return await call_next(request) - - # Skip authentication if auth_provider is 'none' - if self.settings.auth_provider == "none": - # Add anonymous user to request state - request.state.user = User( - id="anonymous", - fullname="anonymous", - email="anonymous@localhost", - role=UserRole.USER, - is_active=True - ) - return await call_next(request) - - signature = request.query_params.get("signature") - if signature: - if not self.token_service.verify_signed_url(signature): - return self._unauthorized_response("Invalid signature") - - # Extract authentication information - auth_header = request.headers.get("Authorization") - if not auth_header: - return self._unauthorized_response("Missing Authorization header") - - try: - # For basic auth - if auth_header.startswith("Basic "): - user = await self._handle_basic_auth(auth_header) - # For bearer token (if implemented) - elif auth_header.startswith("Bearer "): - user = await self._handle_bearer_auth(auth_header) - else: - return self._unauthorized_response("Invalid authentication scheme") - - if not user: - return self._unauthorized_response("Authentication failed") - - if not user.is_active: - return self._unauthorized_response("User account is inactive") - - # Add user to request state - request.state.user = user - - except Exception as e: - logger.error(f"Authentication error: {e}") - return self._unauthorized_response("Authentication failed") - - return await call_next(request) - - async def _handle_basic_auth(self, auth_header: str) -> Optional[User]: - """Handle HTTP Basic Authentication""" - try: - import base64 - - # Extract credentials - encoded_credentials = auth_header.split(" ")[1] - decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8') - username, password = decoded_credentials.split(":", 1) - - # Authenticate user - user = await self.auth_service.authenticate_user(username, password) - return user - - except Exception as e: - logger.warning(f"Basic auth failed: {e}") - return None - - async def _handle_bearer_auth(self, auth_header: str) -> Optional[User]: - """Handle Bearer Token Authentication""" - try: - # Extract token - token = auth_header.split(" ")[1] - - # Verify token and get user - user = await self.auth_service.verify_token(token) - return user - - except Exception as e: - logger.warning(f"Bearer token auth failed: {e}") - return None - - def _is_resource_access_with_token(self, request: Request) -> bool: - """Check if request is resource access with valid token parameter or signed URL""" - try: - signature = request.query_params.get("signature") - if signature: - return self._verify_signed_url_access(request) - - return False - - except Exception as e: - logger.error(f"Error checking resource access: {e}") - return False - - def _verify_signed_url_access(self, request: Request) -> bool: - """Verify signed URL access""" - try: - # Verify the signed URL directly - full_url = str(request.url) - is_valid = self.token_service.verify_signed_url(full_url) - - if is_valid: - logger.info(f"Access authenticated via signed URL for path: {request.url.path}") - return True - else: - logger.warning(f"Invalid signed URL for path: {request.url.path}") - return False - - except Exception as e: - logger.error(f"Error checking signed URL access: {e}") - return False - - def _unauthorized_response(self, message: str) -> JSONResponse: - """Return unauthorized response""" - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "code": 401, - "msg": message, - "data": None - } - ) - \ No newline at end of file diff --git a/backend/app/interfaces/schemas/event.py b/backend/app/interfaces/schemas/event.py index feed6a7b..7cfca4f6 100644 --- a/backend/app/interfaces/schemas/event.py +++ b/backend/app/interfaces/schemas/event.py @@ -3,8 +3,8 @@ from datetime import datetime from dataclasses import dataclass from app.domain.models.plan import ExecutionStatus, Step -from app.domain.models.file import FileInfo -from app.domain.models.event import ToolStatus +from app.interfaces.schemas.file import FileInfoResponse +from app.domain.models.event import ToolStatus, ToolContent, BrowserToolContent from app.domain.models.event import ( AgentEvent, ErrorEvent, @@ -60,20 +60,20 @@ def from_event(cls, event: AgentEvent) -> Self: class MessageEventData(BaseEventData): role: Literal["user", "assistant"] content: str - attachments: Optional[List[FileInfo]] = None + attachments: Optional[List[FileInfoResponse]] = None class MessageSSEEvent(BaseSSEEvent): event: Literal["message"] = "message" data: MessageEventData @classmethod - def from_event(cls, event: MessageEvent) -> Self: + async def from_event_async(cls, event: MessageEvent) -> Self: return cls( data=MessageEventData( **BaseEventData.base_event_data(event), role=event.role, content=event.message, - attachments=event.attachments + attachments=[await FileInfoResponse.from_file_info(attachment) for attachment in event.attachments] ) ) @@ -83,14 +83,18 @@ class ToolEventData(BaseEventData): status: ToolStatus function: str args: Dict[str, Any] - content: Optional[Any] = None + content: Optional[ToolContent] = None class ToolSSEEvent(BaseSSEEvent): event: Literal["tool"] = "tool" data: ToolEventData @classmethod - def from_event(cls, event: ToolEvent) -> Self: + async def from_event_async(cls, event: ToolEvent) -> Self: + content = event.tool_content + if isinstance(content, BrowserToolContent): + from app.interfaces.dependencies import get_file_service + content = BrowserToolContent(screenshot=await get_file_service().create_signed_url(content.screenshot)) return cls( data=ToolEventData( **BaseEventData.base_event_data(event), @@ -99,7 +103,7 @@ def from_event(cls, event: ToolEvent) -> Self: status=event.status, function=event.function_name, args=event.function_args, - content=event.tool_content + content=content ) ) @@ -231,7 +235,7 @@ def _get_event_type_mapping() -> Dict[str, EventMapping]: return mapping @staticmethod - def event_to_sse_event(event: AgentEvent) -> AgentSSEEvent: + async def event_to_sse_event(event: AgentEvent) -> AgentSSEEvent: # Get mapping dynamically event_type_mapping = EventMapper._get_event_type_mapping() @@ -239,15 +243,19 @@ def event_to_sse_event(event: AgentEvent) -> AgentSSEEvent: event_mapping = event_type_mapping.get(event.type) if event_mapping: - # Prioritize from_event class method - sse_event = event_mapping.sse_event_class.from_event(event) + # Prioritize from_event_async class method if exists, otherwise use from_event + sse_event_class = event_mapping.sse_event_class + if hasattr(sse_event_class, 'from_event_async'): + sse_event = await sse_event_class.from_event_async(event) + else: + sse_event = sse_event_class.from_event(event) return sse_event # If no matching type found, return base event return CommonEventData.from_event(event) @staticmethod - def events_to_sse_events(events: List[AgentEvent]) -> List[AgentSSEEvent]: + async def events_to_sse_events(events: List[AgentEvent]) -> List[AgentSSEEvent]: """Create SSE event list from event list""" return list(filter(lambda x: x is not None, [ - EventMapper.event_to_sse_event(event) for event in events if event + await EventMapper.event_to_sse_event(event) for event in events if event ])) \ No newline at end of file diff --git a/backend/app/interfaces/schemas/file.py b/backend/app/interfaces/schemas/file.py index c2bb6e0d..643da24d 100644 --- a/backend/app/interfaces/schemas/file.py +++ b/backend/app/interfaces/schemas/file.py @@ -1,5 +1,7 @@ from pydantic import BaseModel from typing import Optional, Dict, Any +from datetime import datetime +from app.domain.models.file import FileInfo class FileViewRequest(BaseModel): @@ -13,20 +15,26 @@ class FileViewResponse(BaseModel): file: str -class FileUploadResponse(BaseModel): - """File upload response schema""" - file_id: str - filename: str - size: int - upload_date: str - message: str - - class FileInfoResponse(BaseModel): """File info response schema""" file_id: str filename: str content_type: Optional[str] size: int - upload_date: str + upload_date: Optional[datetime] = None metadata: Optional[Dict[str, Any]] + file_url: Optional[str] + + @staticmethod + async def from_file_info(file_info: FileInfo) -> "FileInfoResponse": + from app.interfaces.dependencies import get_file_service + file_service = get_file_service() + return FileInfoResponse( + file_id=file_info.file_id, + filename=file_info.filename, + content_type=file_info.content_type, + size=file_info.size, + upload_date=file_info.upload_date, + metadata=file_info.metadata, + file_url=await file_service.create_signed_url(file_info.file_id) + ) diff --git a/backend/app/interfaces/schemas/session.py b/backend/app/interfaces/schemas/session.py index 0ea606ae..b2f13f8b 100644 --- a/backend/app/interfaces/schemas/session.py +++ b/backend/app/interfaces/schemas/session.py @@ -28,6 +28,7 @@ class GetSessionResponse(BaseModel): title: Optional[str] = None status: SessionStatus events: List[AgentSSEEvent] = [] + is_shared: bool = False class ListSessionItem(BaseModel): @@ -38,6 +39,7 @@ class ListSessionItem(BaseModel): latest_message_at: Optional[int] = None status: SessionStatus unread_message_count: int + is_shared: bool = False class ListSessionResponse(BaseModel): @@ -57,3 +59,18 @@ class ShellViewResponse(BaseModel): output: str session_id: str console: Optional[List[ConsoleRecord]] = None + + +class ShareSessionResponse(BaseModel): + """Share session response schema""" + session_id: str + is_shared: bool + + +class SharedSessionResponse(BaseModel): + """Shared session response schema (for public access)""" + session_id: str + title: Optional[str] = None + status: SessionStatus + events: List[AgentSSEEvent] = [] + is_shared: bool diff --git a/backend/app/main.py b/backend/app/main.py index 49378dd2..2ceec5e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,6 @@ from app.infrastructure.logging import setup_logging from app.interfaces.errors.exception_handlers import register_exception_handlers from app.infrastructure.models.documents import AgentDocument, SessionDocument, UserDocument -from app.interfaces.middleware.auth import AuthMiddleware from beanie import init_beanie # Initialize logging system @@ -73,9 +72,6 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -# Add authentication middleware -app.add_middleware(AuthMiddleware) - # Register exception handlers register_exception_handlers(app) diff --git a/docs/en/roadmap.md b/docs/en/roadmap.md index 3c7b25f8..b417f0fd 100644 --- a/docs/en/roadmap.md +++ b/docs/en/roadmap.md @@ -24,7 +24,7 @@ ## Task Sessions - * [ ] Support sharing + * [x] Support sharing ## Infrastructure diff --git a/docs/roadmap.md b/docs/roadmap.md index 929b5f9c..40a34e12 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,7 +24,7 @@ ## 任务会话 - * [ ] 支持分享 + * [x] 支持分享 ## 基建 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0606a83..960e017b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "marked": "^15.0.8", "mitt": "^3.0.1", "monaco-editor": "^0.52.2", - "reka-ui": "^2.4.1", + "reka-ui": "^2.5.0", "tailwind-merge": "^1.14.0", "tw-animate-css": "^1.3.7", "vue": "^3.3.4", @@ -2557,9 +2557,9 @@ } }, "node_modules/reka-ui": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.1.tgz", - "integrity": "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz", + "integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/frontend/package.json b/frontend/package.json index 849b7e62..45541bf2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ "marked": "^15.0.8", "mitt": "^3.0.1", "monaco-editor": "^0.52.2", - "reka-ui": "^2.4.1", + "reka-ui": "^2.5.0", "tailwind-merge": "^1.14.0", "tw-animate-css": "^1.3.7", "vue": "^3.3.4", diff --git a/frontend/src/api/agent.ts b/frontend/src/api/agent.ts index 5da38314..dba7ac49 100644 --- a/frontend/src/api/agent.ts +++ b/frontend/src/api/agent.ts @@ -1,7 +1,7 @@ // Backend API service import { apiClient, API_CONFIG, ApiResponse, createSSEConnection, SSECallbacks } from './client'; import { AgentSSEEvent } from '../types/event'; -import { CreateSessionResponse, GetSessionResponse, ShellViewResponse, FileViewResponse, ListSessionResponse, SignedUrlResponse } from '../types/response'; +import { CreateSessionResponse, GetSessionResponse, ShellViewResponse, FileViewResponse, ListSessionResponse, SignedUrlResponse, ShareSessionResponse, SharedSessionResponse } from '../types/response'; import type { FileInfo } from './file'; @@ -137,4 +137,68 @@ export async function getSessionFiles(sessionId: string): Promise { export async function clearUnreadMessageCount(sessionId: string): Promise { await apiClient.post>(`/sessions/${sessionId}/clear_unread_message_count`); +} + +/** + * Share a session to make it publicly accessible + * @param sessionId Session ID to share + * @returns Share session response with current sharing status + * + * @example + * ```typescript + * // Share a session + * const result = await shareSession('session123'); + * console.log(result.is_shared); // true + * ``` + */ +export async function shareSession(sessionId: string): Promise { + const response = await apiClient.post>(`/sessions/${sessionId}/share`); + return response.data.data; +} + +/** + * Unshare a session to make it private again + * @param sessionId Session ID to unshare + * @returns Share session response with current sharing status + * + * @example + * ```typescript + * // Unshare a session + * const result = await unshareSession('session123'); + * console.log(result.is_shared); // false + * ``` + */ +export async function unshareSession(sessionId: string): Promise { + const response = await apiClient.delete>(`/sessions/${sessionId}/share`); + return response.data.data; +} + +/** + * Get a shared session without authentication + * This endpoint allows public access to sessions that have been marked as shared. + * No authentication token is required. + * + * @param sessionId Session ID to retrieve + * @returns Shared session data (accessible publicly) + * + * @example + * ```typescript + * // Get a shared session (no auth required) + * try { + * const sharedSession = await getSharedSession('session123'); + * console.log(sharedSession.title); + * console.log(sharedSession.events); + * } catch (error) { + * console.error('Session not found or not shared'); + * } + * ``` + */ +export async function getSharedSession(sessionId: string): Promise { + const response = await apiClient.get>(`/sessions/shared/${sessionId}`); + return response.data.data; +} + +export async function getSharedSessionFiles(sessionId: string): Promise { + const response = await apiClient.get>(`/sessions/${sessionId}/share/files`); + return response.data.data; } \ No newline at end of file diff --git a/frontend/src/api/file.ts b/frontend/src/api/file.ts index f025d6d5..c96fd06e 100644 --- a/frontend/src/api/file.ts +++ b/frontend/src/api/file.ts @@ -12,6 +12,7 @@ export interface FileInfo { size: number; upload_date: string; metadata?: Record; + file_url?: string; } @@ -44,7 +45,7 @@ export async function uploadFile(file: File, metadata?: Record): Pr * @returns File download result */ export async function downloadFile(fileId: string): Promise { - const response = await apiClient.get(`/files/${fileId}`, { + const response = await apiClient.get(`/files/${fileId}/download`, { responseType: 'blob', }); @@ -96,14 +97,15 @@ export async function createFileSignedUrl(fileId: string, expireMinutes: number /** * Get file download URL - * @param fileId File ID - * @param expireMinutes URL/Token expiration time in minutes (default: 15) + * @param file File info * @returns Promise resolving to file download URL string */ export async function getFileDownloadUrl( - fileId: string, - expireMinutes: number = 15, + fileInfo: FileInfo, ): Promise { - const signedUrlResponse = await createFileSignedUrl(fileId, expireMinutes); + if (fileInfo.file_url) { + return `${API_CONFIG.host}${fileInfo.file_url}`; + } + const signedUrlResponse = await createFileSignedUrl(fileInfo.file_id); return `${API_CONFIG.host}${signedUrlResponse.signed_url}`; } diff --git a/frontend/src/assets/global.css b/frontend/src/assets/global.css index 9fad398d..a67db816 100644 --- a/frontend/src/assets/global.css +++ b/frontend/src/assets/global.css @@ -24,15 +24,6 @@ body,html { user-select: none; } -/* Remove focus outline/blue border */ -*:focus { - outline: none; -} - -*:focus-visible { - outline: none; -} - .ltr\:border-l { border-left-width: 1px; } \ No newline at end of file diff --git a/frontend/src/components/FilePanel.vue b/frontend/src/components/FilePanel.vue index 1abca044..6fdf325f 100644 --- a/frontend/src/components/FilePanel.vue +++ b/frontend/src/components/FilePanel.vue @@ -76,7 +76,7 @@ const fileType = computed(() => { const download = async () => { if (!fileInfo.value) return - const url = await getFileDownloadUrl(fileInfo.value.file_id) + const url = await getFileDownloadUrl(fileInfo.value) window.open(url, '_blank') } diff --git a/frontend/src/components/FilePanelContent.vue b/frontend/src/components/FilePanelContent.vue index 8de05560..8340e48c 100644 --- a/frontend/src/components/FilePanelContent.vue +++ b/frontend/src/components/FilePanelContent.vue @@ -57,7 +57,7 @@ const fileType = computed(() => { }); const download = async () => { - const url = await getFileDownloadUrl(props.file.file_id); + const url = await getFileDownloadUrl(props.file); window.open(url, '_blank'); }; diff --git a/frontend/src/components/SessionFileList.vue b/frontend/src/components/SessionFileList.vue index ab84b0dc..aa9cb3c1 100644 --- a/frontend/src/components/SessionFileList.vue +++ b/frontend/src/components/SessionFileList.vue @@ -39,7 +39,7 @@ {{ formatRelativeTime(parseISODateTime(file.upload_date)) }} - @@ -40,6 +40,7 @@ const emit = defineEmits<{ defineProps<{ sessionId?: string realTime: boolean + isShare: boolean }>() const showToolPanel = (content: ToolContent, isLive: boolean = false) => { diff --git a/frontend/src/components/ToolPanelContent.vue b/frontend/src/components/ToolPanelContent.vue index 84937a7c..f1dfac81 100644 --- a/frontend/src/components/ToolPanelContent.vue +++ b/frontend/src/components/ToolPanelContent.vue @@ -26,7 +26,7 @@
+ :toolContent="toolContent" :isShare="isShare" />
+
- + +
- @@ -85,13 +146,17 @@ import { } from '../types/event'; import ToolPanel from '../components/ToolPanel.vue' import PlanPanel from '../components/PlanPanel.vue'; -import { ArrowDown, FileSearch, PanelLeft } from 'lucide-vue-next'; -import { showErrorToast } from '../utils/toast'; +import { ArrowDown, FileSearch, PanelLeft, Lock, Globe, Link, Check } from 'lucide-vue-next'; +import ShareIcon from '@/components/icons/ShareIcon.vue'; +import { showErrorToast, showSuccessToast } from '../utils/toast'; import type { FileInfo } from '../api/file'; import { useLeftPanel } from '../composables/useLeftPanel' import { useSessionFileList } from '../composables/useSessionFileList' import { useFilePanel } from '../composables/useFilePanel' +import { copyToClipboard } from '../utils/dom' import { SessionStatus } from '../types/response'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import LoadingIndicator from '@/components/ui/LoadingIndicator.vue'; const router = useRouter() const { t } = useI18n() @@ -115,7 +180,10 @@ const createInitialState = () => ({ lastTool: undefined as ToolContent | undefined, lastEventId: undefined as string | undefined, cancelCurrentChat: null as (() => void) | null, - attachments: [] as FileInfo[] + attachments: [] as FileInfo[], + shareMode: 'private' as 'private' | 'public', // Default to private mode + linkCopied: false, + sharingLoading: false // Loading state for share operations }); // Create reactive state @@ -136,7 +204,10 @@ const { lastTool, lastEventId, cancelCurrentChat, - attachments + attachments, + shareMode, + linkCopied, + sharingLoading } = toRefs(state); // Non-state refs that don't need reset @@ -370,6 +441,8 @@ const restoreSession = async () => { return; } const session = await agentApi.getSession(sessionId.value); + // Initialize share mode based on session state + shareMode.value = session.is_shared ? 'public' : 'private'; realTime.value = false; for (const event of session.events) { handleEvent(event); @@ -472,33 +545,75 @@ const handleStop = () => { const handleFileListShow = () => { showSessionFileList() } - - - diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index dfd30c30..1cce078b 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -99,7 +99,7 @@ const avatarLetter = computed(() => { // User menu state const showUserMenu = ref(false); -const userMenuTimeout = ref(null); +const userMenuTimeout = ref(null); // Show user menu on hover const handleUserMenuEnter = () => { diff --git a/frontend/src/pages/ShareLayout.vue b/frontend/src/pages/ShareLayout.vue new file mode 100644 index 00000000..aec629cf --- /dev/null +++ b/frontend/src/pages/ShareLayout.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/pages/SharePage.vue b/frontend/src/pages/SharePage.vue new file mode 100644 index 00000000..8ef9e1dc --- /dev/null +++ b/frontend/src/pages/SharePage.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 211bf997..0789e7d4 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -16,6 +16,7 @@ export interface GetSessionResponse { title: string | null; status: SessionStatus; events: AgentSSEEvent[]; + is_shared: boolean; } export interface ListSessionItem { @@ -25,6 +26,7 @@ export interface ListSessionItem { latest_message_at: number | null; status: SessionStatus; unread_message_count: number; + is_shared: boolean; } export interface ListSessionResponse { @@ -52,4 +54,17 @@ export interface SignedUrlResponse { signed_url: string; expires_in: number; } + +export interface ShareSessionResponse { + session_id: string; + is_shared: boolean; +} + +export interface SharedSessionResponse { + session_id: string; + title: string | null; + status: SessionStatus; + events: AgentSSEEvent[]; + is_shared: boolean; +} \ No newline at end of file diff --git a/frontend/src/utils/dom.ts b/frontend/src/utils/dom.ts index 1ffee1d1..3f1b5a1e 100644 --- a/frontend/src/utils/dom.ts +++ b/frontend/src/utils/dom.ts @@ -42,4 +42,65 @@ export function getParentElement( return parent } +/** + * Copy text to clipboard with fallback support + * @param text - Text to copy to clipboard + * @returns Promise - Returns true if copy was successful + */ +export async function copyToClipboard(text: string): Promise { + // Check if modern clipboard API is available + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + console.log('Text copied to clipboard using Clipboard API'); + return true; + } catch (error) { + console.error('Clipboard API failed:', error); + // Fall through to fallback method + } + } + + // Fallback method for older browsers or when clipboard API fails + try { + console.log('Copying text to clipboard using fallback method'); + + // Store current active element to restore focus later + const activeElement = document.activeElement as HTMLElement; + + // Create a temporary textarea element + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + textArea.style.left = '-9999px'; + textArea.style.opacity = '0'; + textArea.setAttribute('readonly', ''); + + // Add to DOM, focus, select and copy + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + + // Remove the temporary element + document.body.removeChild(textArea); + + // Restore focus to the previous active element to prevent popover from closing + if (activeElement && activeElement.focus) { + activeElement.focus(); + } + + if (successful) { + console.log('Text copied using fallback method'); + return true; + } + + return false; + } catch (error) { + console.error('All copy methods failed:', error); + return false; + } +} + \ No newline at end of file