Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

file_preview.py 7.0KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import logging
  2. from urllib.parse import quote
  3. from flask import Response
  4. from flask_restx import Resource, reqparse
  5. from controllers.service_api import service_api_ns
  6. from controllers.service_api.app.error import (
  7. FileAccessDeniedError,
  8. FileNotFoundError,
  9. )
  10. from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
  11. from extensions.ext_database import db
  12. from extensions.ext_storage import storage
  13. from models.model import App, EndUser, Message, MessageFile, UploadFile
  14. logger = logging.getLogger(__name__)
  15. # Define parser for file preview API
  16. file_preview_parser = reqparse.RequestParser()
  17. file_preview_parser.add_argument(
  18. "as_attachment", type=bool, required=False, default=False, location="args", help="Download as attachment"
  19. )
  20. @service_api_ns.route("/files/<uuid:file_id>/preview")
  21. class FilePreviewApi(Resource):
  22. """
  23. Service API File Preview endpoint
  24. Provides secure file preview/download functionality for external API users.
  25. Files can only be accessed if they belong to messages within the requesting app's context.
  26. """
  27. @service_api_ns.expect(file_preview_parser)
  28. @service_api_ns.doc("preview_file")
  29. @service_api_ns.doc(description="Preview or download a file uploaded via Service API")
  30. @service_api_ns.doc(params={"file_id": "UUID of the file to preview"})
  31. @service_api_ns.doc(
  32. responses={
  33. 200: "File retrieved successfully",
  34. 401: "Unauthorized - invalid API token",
  35. 403: "Forbidden - file access denied",
  36. 404: "File not found",
  37. }
  38. )
  39. @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
  40. def get(self, app_model: App, end_user: EndUser, file_id: str):
  41. """
  42. Preview/Download a file that was uploaded via Service API.
  43. Provides secure file preview/download functionality.
  44. Files can only be accessed if they belong to messages within the requesting app's context.
  45. """
  46. file_id = str(file_id)
  47. # Parse query parameters
  48. args = file_preview_parser.parse_args()
  49. # Validate file ownership and get file objects
  50. _, upload_file = self._validate_file_ownership(file_id, app_model.id)
  51. # Get file content generator
  52. try:
  53. generator = storage.load(upload_file.key, stream=True)
  54. except Exception as e:
  55. raise FileNotFoundError(f"Failed to load file content: {str(e)}")
  56. # Build response with appropriate headers
  57. response = self._build_file_response(generator, upload_file, args["as_attachment"])
  58. return response
  59. def _validate_file_ownership(self, file_id: str, app_id: str) -> tuple[MessageFile, UploadFile]:
  60. """
  61. Validate that the file belongs to a message within the requesting app's context
  62. Security validations performed:
  63. 1. File exists in MessageFile table (was used in a conversation)
  64. 2. Message belongs to the requesting app
  65. 3. UploadFile record exists and is accessible
  66. 4. File tenant matches app tenant (additional security layer)
  67. Args:
  68. file_id: UUID of the file to validate
  69. app_id: UUID of the requesting app
  70. Returns:
  71. Tuple of (MessageFile, UploadFile) if validation passes
  72. Raises:
  73. FileNotFoundError: File or related records not found
  74. FileAccessDeniedError: File does not belong to the app's context
  75. """
  76. try:
  77. # Input validation
  78. if not file_id or not app_id:
  79. raise FileAccessDeniedError("Invalid file or app identifier")
  80. # First, find the MessageFile that references this upload file
  81. message_file = db.session.query(MessageFile).where(MessageFile.upload_file_id == file_id).first()
  82. if not message_file:
  83. raise FileNotFoundError("File not found in message context")
  84. # Get the message and verify it belongs to the requesting app
  85. message = (
  86. db.session.query(Message).where(Message.id == message_file.message_id, Message.app_id == app_id).first()
  87. )
  88. if not message:
  89. raise FileAccessDeniedError("File access denied: not owned by requesting app")
  90. # Get the actual upload file record
  91. upload_file = db.session.query(UploadFile).where(UploadFile.id == file_id).first()
  92. if not upload_file:
  93. raise FileNotFoundError("Upload file record not found")
  94. # Additional security: verify tenant isolation
  95. app = db.session.query(App).where(App.id == app_id).first()
  96. if app and upload_file.tenant_id != app.tenant_id:
  97. raise FileAccessDeniedError("File access denied: tenant mismatch")
  98. return message_file, upload_file
  99. except (FileNotFoundError, FileAccessDeniedError):
  100. # Re-raise our custom exceptions
  101. raise
  102. except Exception as e:
  103. # Log unexpected errors for debugging
  104. logger.exception(
  105. "Unexpected error during file ownership validation",
  106. extra={"file_id": file_id, "app_id": app_id, "error": str(e)},
  107. )
  108. raise FileAccessDeniedError("File access validation failed")
  109. def _build_file_response(self, generator, upload_file: UploadFile, as_attachment: bool = False) -> Response:
  110. """
  111. Build Flask Response object with appropriate headers for file streaming
  112. Args:
  113. generator: File content generator from storage
  114. upload_file: UploadFile database record
  115. as_attachment: Whether to set Content-Disposition as attachment
  116. Returns:
  117. Flask Response object with streaming file content
  118. """
  119. response = Response(
  120. generator,
  121. mimetype=upload_file.mime_type,
  122. direct_passthrough=True,
  123. headers={},
  124. )
  125. # Add Content-Length if known
  126. if upload_file.size and upload_file.size > 0:
  127. response.headers["Content-Length"] = str(upload_file.size)
  128. # Add Accept-Ranges header for audio/video files to support seeking
  129. if upload_file.mime_type in [
  130. "audio/mpeg",
  131. "audio/wav",
  132. "audio/mp4",
  133. "audio/ogg",
  134. "audio/flac",
  135. "audio/aac",
  136. "video/mp4",
  137. "video/webm",
  138. "video/quicktime",
  139. "audio/x-m4a",
  140. ]:
  141. response.headers["Accept-Ranges"] = "bytes"
  142. # Set Content-Disposition for downloads
  143. if as_attachment and upload_file.name:
  144. encoded_filename = quote(upload_file.name)
  145. response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
  146. # Override content-type for downloads to force download
  147. response.headers["Content-Type"] = "application/octet-stream"
  148. # Add caching headers for performance
  149. response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
  150. return response