""" Copyright 2024-2026 ChatterMate Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status, Header, Request, Query from fastapi.responses import FileResponse, JSONResponse from typing import Optional, Tuple import uuid import os from datetime import datetime from urllib.parse import quote from sqlalchemy.orm import Session from app.core.config import settings from app.core.s3 import upload_file_to_s3, get_s3_signed_url from app.core.local_file_urls import verify_local_signature from app.core.logger import get_logger from app.core.security import verify_conversation_token from app.models import User from app.core.auth import get_current_user from app.database import get_db logger = get_logger(__name__) router = APIRouter() # Allowed file types and size limits ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'} ALLOWED_DOCUMENT_TYPES = {'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'} ALLOWED_FILE_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB async def get_current_user_or_widget( request: Request, db: Session = Depends(get_db), authorization: Optional[str] = Header(None), x_conversation_token: Optional[str] = Header(None) ) -> dict: """ Get current user from JWT token, cookies, or widget conversation token Returns dict with user info or widget info """ # Try to get user from cookies or Authorization header (admin/agent users) try: # Call get_current_user with the request - it handles both cookies and headers user = await get_current_user(request, None, db) if user: return { "type": "user", "user_id": str(user.id), "org_id": str(user.organization_id) } except HTTPException: # User auth failed, continue to try other methods pass except Exception as e: logger.error(f"Error authenticating user: {str(e)}") # Try to get widget info from conversation token if x_conversation_token: try: payload = verify_conversation_token(x_conversation_token) if payload: return { "type": "widget", "widget_id": payload.get("widget_id"), "org_id": payload.get("org_id"), "customer_id": payload.get("customer_id") } except Exception as e: logger.error(f"Error verifying conversation token: {str(e)}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) def _not_found() -> HTTPException: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") def attachment_key(file_path: str) -> Tuple[str, str]: """Parse a download path into (org_id, storage key), or 404. Uploads only ever land at `chat_attachments//.` (see FileUploadService.upload_file), so anything else is not a servable attachment. Matching that shape exactly rules out traversal: no `..` segment can survive a three-part match on a UUID directory. """ # Accept the stored form ("/uploads/chat_attachments/...") and the bare key. clean_path = file_path.lstrip('/') if clean_path.startswith('uploads/'): clean_path = clean_path[len('uploads/'):] parts = clean_path.split('/') if len(parts) != 3 or parts[0] != 'chat_attachments' or not parts[2] or parts[2] in ('.', '..'): raise _not_found() try: path_org_id = str(uuid.UUID(parts[1])) except ValueError: raise _not_found() return path_org_id, '/'.join(('chat_attachments', path_org_id, parts[2])) def authorized_attachment_key(file_path: str, org_id: Optional[str]) -> str: """Resolve a download path to a storage key the caller's org may read.""" if not org_id: raise _not_found() path_org_id, key = attachment_key(file_path) # 404 rather than 403: whether another org's attachment exists is itself # something the caller shouldn't learn. if path_org_id != str(org_id): raise _not_found() return key # Visitor-uploaded files (SVG included) are served from the API origin, so one # opened directly must not run script there. Only scripts are blocked: a # sandbox CSP would also stop the browser's PDF viewer. ATTACHMENT_HEADERS = { "Content-Security-Policy": "script-src 'none'", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=300", } def _inline_attachment(path: str, filename: str) -> FileResponse: return FileResponse(path, content_disposition_type="inline", filename=filename, headers=ATTACHMENT_HEADERS) @router.get("/local/{file_path:path}") async def serve_signed_local_file( file_path: str, exp: Optional[int] = Query(None), sig: Optional[str] = Query(None), ): """Serve a local-storage attachment through a link from sign_local_key. The signature is the authorization, as with an S3 presigned URL, because the widget loads attachments with plain / tags. """ _, key = attachment_key(file_path) if not verify_local_signature(key, exp, sig): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired link") path = os.path.join("uploads", key) if not os.path.isfile(path): raise _not_found() return _inline_attachment(path, os.path.basename(key)) @router.get("/download/{file_path:path}") async def download_file( file_path: str, request: Request, db: Session = Depends(get_db), authorization: Optional[str] = Header(None), x_conversation_token: Optional[str] = Header(None) ): """ Download/serve a file from AWS S3 or local storage Usage: GET /api/v1/files/download/chat_attachments/org-id/filename.jpg """ try: from fastapi.responses import StreamingResponse from app.core.s3 import get_s3_client # Authentication is required, and the result is what authorizes the # read below. This used to swallow the failure and serve the file # anyway, making every org's attachments world-readable by path. auth_info = await get_current_user_or_widget( request, db, authorization, x_conversation_token) storage_key = authorized_attachment_key(file_path, auth_info.get("org_id")) if not settings.S3_FILE_STORAGE: local_file_path = os.path.join("uploads", storage_key) if not os.path.isfile(local_file_path): raise _not_found() return _inline_attachment(local_file_path, os.path.basename(storage_key)) # For AWS S3 - stream file from S3 try: s3_client = get_s3_client() except Exception as client_err: logger.error(f"Failed to create S3 client: {str(client_err)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create S3 client" ) # S3 keys don't include the /uploads/ prefix s3_key = storage_key bucket = settings.S3_BUCKET # Check if the file exists try: s3_client.head_object(Bucket=bucket, Key=s3_key) except s3_client.exceptions.NoSuchKey: logger.warning(f"File not found in S3: {s3_key}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) except Exception as head_err: logger.error(f"Error checking file in S3: {str(head_err)}", exc_info=True) # Get the object from S3 try: response = s3_client.get_object(Bucket=bucket, Key=s3_key) except s3_client.exceptions.NoSuchKey: logger.warning(f"File not found in S3: {s3_key}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) except Exception as s3_err: logger.error(f"S3 error: {str(s3_err)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve file from S3" ) # Stream the file back to client def generate(): while True: chunk = response['Body'].read(8192) if not chunk: break yield chunk # Determine content type content_type = response.get('ContentType', 'application/octet-stream') # Extract filename from the validated key filename = storage_key.split('/')[-1] logger.info(f"Streaming file from S3: {filename}, content-type: {content_type}") return StreamingResponse( generate(), media_type=content_type, headers={ **ATTACHMENT_HEADERS, "Content-Disposition": f"inline; filename*=utf-8''{quote(filename)}", } ) except HTTPException: raise except Exception as e: logger.error(f"Error downloading file {file_path}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to download file" )