| 
                        123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 | 
                        - import logging
 - from urllib.parse import quote
 - 
 - from flask import Response
 - from flask_restx import Resource, reqparse
 - 
 - from controllers.service_api import api
 - from controllers.service_api.app.error import (
 -     FileAccessDeniedError,
 -     FileNotFoundError,
 - )
 - from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
 - from extensions.ext_database import db
 - from extensions.ext_storage import storage
 - from models.model import App, EndUser, Message, MessageFile, UploadFile
 - 
 - logger = logging.getLogger(__name__)
 - 
 - 
 - class FilePreviewApi(Resource):
 -     """
 -     Service API File Preview endpoint
 - 
 -     Provides secure file preview/download functionality for external API users.
 -     Files can only be accessed if they belong to messages within the requesting app's context.
 -     """
 - 
 -     @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
 -     def get(self, app_model: App, end_user: EndUser, file_id: str):
 -         """
 -         Preview/Download a file that was uploaded via Service API
 - 
 -         Args:
 -             app_model: The authenticated app model
 -             end_user: The authenticated end user (optional)
 -             file_id: UUID of the file to preview
 - 
 -         Query Parameters:
 -             user: Optional user identifier
 -             as_attachment: Boolean, whether to download as attachment (default: false)
 - 
 -         Returns:
 -             Stream response with file content
 - 
 -         Raises:
 -             FileNotFoundError: File does not exist
 -             FileAccessDeniedError: File access denied (not owned by app)
 -         """
 -         file_id = str(file_id)
 - 
 -         # Parse query parameters
 -         parser = reqparse.RequestParser()
 -         parser.add_argument("as_attachment", type=bool, required=False, default=False, location="args")
 -         args = parser.parse_args()
 - 
 -         # Validate file ownership and get file objects
 -         message_file, upload_file = self._validate_file_ownership(file_id, app_model.id)
 - 
 -         # Get file content generator
 -         try:
 -             generator = storage.load(upload_file.key, stream=True)
 -         except Exception as e:
 -             raise FileNotFoundError(f"Failed to load file content: {str(e)}")
 - 
 -         # Build response with appropriate headers
 -         response = self._build_file_response(generator, upload_file, args["as_attachment"])
 - 
 -         return response
 - 
 -     def _validate_file_ownership(self, file_id: str, app_id: str) -> tuple[MessageFile, UploadFile]:
 -         """
 -         Validate that the file belongs to a message within the requesting app's context
 - 
 -         Security validations performed:
 -         1. File exists in MessageFile table (was used in a conversation)
 -         2. Message belongs to the requesting app
 -         3. UploadFile record exists and is accessible
 -         4. File tenant matches app tenant (additional security layer)
 - 
 -         Args:
 -             file_id: UUID of the file to validate
 -             app_id: UUID of the requesting app
 - 
 -         Returns:
 -             Tuple of (MessageFile, UploadFile) if validation passes
 - 
 -         Raises:
 -             FileNotFoundError: File or related records not found
 -             FileAccessDeniedError: File does not belong to the app's context
 -         """
 -         try:
 -             # Input validation
 -             if not file_id or not app_id:
 -                 raise FileAccessDeniedError("Invalid file or app identifier")
 - 
 -             # First, find the MessageFile that references this upload file
 -             message_file = db.session.query(MessageFile).where(MessageFile.upload_file_id == file_id).first()
 - 
 -             if not message_file:
 -                 raise FileNotFoundError("File not found in message context")
 - 
 -             # Get the message and verify it belongs to the requesting app
 -             message = (
 -                 db.session.query(Message).where(Message.id == message_file.message_id, Message.app_id == app_id).first()
 -             )
 - 
 -             if not message:
 -                 raise FileAccessDeniedError("File access denied: not owned by requesting app")
 - 
 -             # Get the actual upload file record
 -             upload_file = db.session.query(UploadFile).where(UploadFile.id == file_id).first()
 - 
 -             if not upload_file:
 -                 raise FileNotFoundError("Upload file record not found")
 - 
 -             # Additional security: verify tenant isolation
 -             app = db.session.query(App).where(App.id == app_id).first()
 -             if app and upload_file.tenant_id != app.tenant_id:
 -                 raise FileAccessDeniedError("File access denied: tenant mismatch")
 - 
 -             return message_file, upload_file
 - 
 -         except (FileNotFoundError, FileAccessDeniedError):
 -             # Re-raise our custom exceptions
 -             raise
 -         except Exception as e:
 -             # Log unexpected errors for debugging
 -             logger.exception(
 -                 "Unexpected error during file ownership validation",
 -                 extra={"file_id": file_id, "app_id": app_id, "error": str(e)},
 -             )
 -             raise FileAccessDeniedError("File access validation failed")
 - 
 -     def _build_file_response(self, generator, upload_file: UploadFile, as_attachment: bool = False) -> Response:
 -         """
 -         Build Flask Response object with appropriate headers for file streaming
 - 
 -         Args:
 -             generator: File content generator from storage
 -             upload_file: UploadFile database record
 -             as_attachment: Whether to set Content-Disposition as attachment
 - 
 -         Returns:
 -             Flask Response object with streaming file content
 -         """
 -         response = Response(
 -             generator,
 -             mimetype=upload_file.mime_type,
 -             direct_passthrough=True,
 -             headers={},
 -         )
 - 
 -         # Add Content-Length if known
 -         if upload_file.size and upload_file.size > 0:
 -             response.headers["Content-Length"] = str(upload_file.size)
 - 
 -         # Add Accept-Ranges header for audio/video files to support seeking
 -         if upload_file.mime_type in [
 -             "audio/mpeg",
 -             "audio/wav",
 -             "audio/mp4",
 -             "audio/ogg",
 -             "audio/flac",
 -             "audio/aac",
 -             "video/mp4",
 -             "video/webm",
 -             "video/quicktime",
 -             "audio/x-m4a",
 -         ]:
 -             response.headers["Accept-Ranges"] = "bytes"
 - 
 -         # Set Content-Disposition for downloads
 -         if as_attachment and upload_file.name:
 -             encoded_filename = quote(upload_file.name)
 -             response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
 -             # Override content-type for downloads to force download
 -             response.headers["Content-Type"] = "application/octet-stream"
 - 
 -         # Add caching headers for performance
 -         response.headers["Cache-Control"] = "public, max-age=3600"  # Cache for 1 hour
 - 
 -         return response
 - 
 - 
 - # Register the API endpoint
 - api.add_resource(FilePreviewApi, "/files/<uuid:file_id>/preview")
 
 
  |