您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

file_preview.py 6.9KB

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