mirror of
https://github.com/langgenius/dify.git
synced 2026-01-14 06:07:33 +08:00
Signed-off-by: -LAN- <laipz8200@outlook.com> Signed-off-by: kenwoodjw <blackxin55+@gmail.com> Signed-off-by: Yongtao Huang <yongtaoh2022@gmail.com> Signed-off-by: yihong0618 <zouzou0208@gmail.com> Signed-off-by: zhanluxianshen <zhanluxianshen@163.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: GuanMu <ballmanjq@gmail.com> Co-authored-by: Davide Delbianco <davide.delbianco@outlook.com> Co-authored-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Co-authored-by: kenwoodjw <blackxin55+@gmail.com> Co-authored-by: Yongtao Huang <yongtaoh2022@gmail.com> Co-authored-by: Yongtao Huang <99629139+hyongtao-db@users.noreply.github.com> Co-authored-by: Qiang Lee <18018968632@163.com> Co-authored-by: 李强04 <liqiang04@gaotu.cn> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: Matri Qi <matrixdom@126.com> Co-authored-by: huayaoyue6 <huayaoyue@163.com> Co-authored-by: Bowen Liang <liangbowen@gf.com.cn> Co-authored-by: znn <jubinkumarsoni@gmail.com> Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: yihong <zouzou0208@gmail.com> Co-authored-by: Muke Wang <shaodwaaron@gmail.com> Co-authored-by: wangmuke <wangmuke@kingsware.cn> Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Co-authored-by: quicksand <quicksandzn@gmail.com> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: Eric Guo <eric.guocz@gmail.com> Co-authored-by: Zhedong Cen <cenzhedong2@126.com> Co-authored-by: jiangbo721 <jiangbo721@163.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: hjlarry <25834719+hjlarry@users.noreply.github.com> Co-authored-by: lxsummer <35754229+lxjustdoit@users.noreply.github.com> Co-authored-by: 湛露先生 <zhanluxianshen@163.com> Co-authored-by: Guangdong Liu <liugddx@gmail.com> Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Yessenia-d <yessenia.contact@gmail.com> Co-authored-by: huangzhuo1949 <167434202+huangzhuo1949@users.noreply.github.com> Co-authored-by: huangzhuo <huangzhuo1@xiaomi.com> Co-authored-by: 17hz <0x149527@gmail.com> Co-authored-by: Amy <1530140574@qq.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: Nite Knite <nkCoding@gmail.com> Co-authored-by: Yeuoly <45712896+Yeuoly@users.noreply.github.com> Co-authored-by: Petrus Han <petrus.hanks@gmail.com> Co-authored-by: iamjoel <2120155+iamjoel@users.noreply.github.com> Co-authored-by: Kalo Chin <frog.beepers.0n@icloud.com> Co-authored-by: Ujjwal Maurya <ujjwalsbx@gmail.com> Co-authored-by: Maries <xh001x@hotmail.com>
188 lines
7.0 KiB
Python
188 lines
7.0 KiB
Python
import logging
|
|
from urllib.parse import quote
|
|
|
|
from flask import Response
|
|
from flask_restx import Resource, reqparse
|
|
|
|
from controllers.service_api import service_api_ns
|
|
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__)
|
|
|
|
|
|
# Define parser for file preview API
|
|
file_preview_parser = reqparse.RequestParser()
|
|
file_preview_parser.add_argument(
|
|
"as_attachment", type=bool, required=False, default=False, location="args", help="Download as attachment"
|
|
)
|
|
|
|
|
|
@service_api_ns.route("/files/<uuid:file_id>/preview")
|
|
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.
|
|
"""
|
|
|
|
@service_api_ns.expect(file_preview_parser)
|
|
@service_api_ns.doc("preview_file")
|
|
@service_api_ns.doc(description="Preview or download a file uploaded via Service API")
|
|
@service_api_ns.doc(params={"file_id": "UUID of the file to preview"})
|
|
@service_api_ns.doc(
|
|
responses={
|
|
200: "File retrieved successfully",
|
|
401: "Unauthorized - invalid API token",
|
|
403: "Forbidden - file access denied",
|
|
404: "File not found",
|
|
}
|
|
)
|
|
@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.
|
|
|
|
Provides secure file preview/download functionality.
|
|
Files can only be accessed if they belong to messages within the requesting app's context.
|
|
"""
|
|
file_id = str(file_id)
|
|
|
|
# Parse query parameters
|
|
args = file_preview_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
|