282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""
|
|
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/<org_id>/<uuid>.<ext>` (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 <img>/<a> 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"
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|