mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 19:15:59 +08:00
170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
from urllib.parse import quote
|
|
from uuid import UUID
|
|
|
|
from flask import Response
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, Field
|
|
|
|
from controllers.common.fields import BinaryFileResponse
|
|
from controllers.common.file_response import enforce_download_for_html
|
|
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_model
|
|
from controllers.console.wraps import model_validate
|
|
from controllers.service_api import service_api_ns
|
|
from controllers.service_api.app.error import (
|
|
FileAccessDeniedError,
|
|
FileNotFoundError,
|
|
)
|
|
from controllers.service_api.schema import binary_response
|
|
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
|
from extensions.ext_application_services import application_services
|
|
from models.model import App, EndUser
|
|
from services.message_file_preview_service import (
|
|
MessageFilePreview,
|
|
MessageFilePreviewAccessDeniedError,
|
|
MessageFilePreviewNotFoundError,
|
|
)
|
|
|
|
|
|
class FilePreviewQuery(BaseModel):
|
|
as_attachment: bool = Field(
|
|
default=False,
|
|
description="If `true`, forces the file to download as an attachment instead of previewing in browser.",
|
|
)
|
|
|
|
|
|
register_schema_model(service_api_ns, FilePreviewQuery)
|
|
register_response_schema_model(service_api_ns, BinaryFileResponse)
|
|
|
|
FILE_PREVIEW_RESPONSE_MEDIA_TYPE = "*/*"
|
|
_RANGE_MEDIA_TYPES = frozenset(
|
|
{
|
|
"audio/aac",
|
|
"audio/flac",
|
|
"audio/mp4",
|
|
"audio/mpeg",
|
|
"audio/ogg",
|
|
"audio/wav",
|
|
"audio/x-m4a",
|
|
"video/mp4",
|
|
"video/quicktime",
|
|
"video/webm",
|
|
}
|
|
)
|
|
|
|
|
|
@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.doc(
|
|
summary="Download File",
|
|
description=(
|
|
"Preview or download uploaded files previously uploaded via the [Upload "
|
|
"File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to "
|
|
"messages within the requesting application."
|
|
),
|
|
tags=["Files"],
|
|
responses={
|
|
200: (
|
|
"Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If "
|
|
"`as_attachment` is `true`, the file is returned as a download with `Content-Disposition: "
|
|
"attachment`."
|
|
),
|
|
403: "`file_access_denied` : Access to the requested file is denied.",
|
|
404: "`file_not_found` : The requested file was not found.",
|
|
},
|
|
)
|
|
@service_api_ns.doc(params=query_params_from_model(FilePreviewQuery))
|
|
@binary_response(service_api_ns, FILE_PREVIEW_RESPONSE_MEDIA_TYPE)
|
|
@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": (
|
|
"The unique identifier of the file to preview, obtained from the "
|
|
"[Upload File](/api-reference/files/upload-file) API response."
|
|
)
|
|
}
|
|
)
|
|
@service_api_ns.doc(
|
|
responses={
|
|
200: "File retrieved successfully",
|
|
401: "Unauthorized - invalid API token",
|
|
403: "Forbidden - file access denied",
|
|
404: "File not found",
|
|
}
|
|
)
|
|
@service_api_ns.response(200, "File retrieved successfully")
|
|
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
|
|
@model_validate(FilePreviewQuery)
|
|
def get(self, args: FilePreviewQuery, app_model: App, end_user: EndUser, file_id: UUID) -> Response:
|
|
"""
|
|
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.
|
|
"""
|
|
try:
|
|
preview = application_services().message_file_previews.get_preview(
|
|
file_id=str(file_id),
|
|
app_id=app_model.id,
|
|
tenant_id=app_model.tenant_id,
|
|
)
|
|
except MessageFilePreviewNotFoundError as error:
|
|
raise FileNotFoundError() from error
|
|
except MessageFilePreviewAccessDeniedError as error:
|
|
raise FileAccessDeniedError() from error
|
|
|
|
return self._build_file_response(preview=preview, as_attachment=args.as_attachment)
|
|
|
|
def _build_file_response(self, *, preview: MessageFilePreview, as_attachment: bool = False) -> Response:
|
|
"""
|
|
Build Flask Response object with appropriate headers for file streaming
|
|
|
|
Args:
|
|
preview: App-scoped file metadata and content stream
|
|
as_attachment: Whether to set Content-Disposition as attachment
|
|
|
|
Returns:
|
|
Flask Response object with streaming file content
|
|
"""
|
|
file = preview.file
|
|
response = Response(
|
|
preview.content,
|
|
mimetype=file.mime_type,
|
|
direct_passthrough=True,
|
|
headers={},
|
|
)
|
|
|
|
# Add Content-Length if known
|
|
if file.size > 0:
|
|
response.headers["Content-Length"] = str(file.size)
|
|
|
|
# Add Accept-Ranges header for audio/video files to support seeking
|
|
if file.mime_type in _RANGE_MEDIA_TYPES:
|
|
response.headers["Accept-Ranges"] = "bytes"
|
|
|
|
# Set Content-Disposition for downloads
|
|
if as_attachment and file.name:
|
|
encoded_filename = quote(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"
|
|
|
|
enforce_download_for_html(
|
|
response,
|
|
mime_type=file.mime_type,
|
|
filename=file.name,
|
|
extension=file.extension,
|
|
)
|
|
|
|
# Add caching headers for performance
|
|
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
|
|
|
|
return response
|