mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
Add an optional S3_PUBLIC_BASE_URL setting that, when configured, lets file controllers 302-redirect signed previews to the object store / CDN instead of streaming bytes through the Dify API. Works with any S3-compatible backend exposing a public domain (Cloudflare R2 custom domain, MinIO public endpoint, Aliyun OSS public domain, etc.) so that egress and request handling for images, attachments, tool outputs, and webapp logos no longer go through the API container. Signature verification is preserved: the API still validates the HMAC before issuing the redirect. When S3_PUBLIC_BASE_URL is unset the behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
6.5 KiB
Python
194 lines
6.5 KiB
Python
from urllib.parse import quote
|
|
|
|
from flask import Response, redirect, request
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, Field
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
import services
|
|
from controllers.common.errors import UnsupportedFileTypeError
|
|
from controllers.common.file_response import enforce_download_for_html
|
|
from controllers.files import files_ns
|
|
from extensions.ext_database import db
|
|
from services.account_service import TenantService
|
|
from services.file_service import FileService
|
|
|
|
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
|
|
|
|
|
class FileSignatureQuery(BaseModel):
|
|
timestamp: str = Field(..., description="Unix timestamp used in the signature")
|
|
nonce: str = Field(..., description="Random string for signature")
|
|
sign: str = Field(..., description="HMAC signature")
|
|
|
|
|
|
class FilePreviewQuery(FileSignatureQuery):
|
|
as_attachment: bool = Field(default=False, description="Whether to download as attachment")
|
|
|
|
|
|
files_ns.schema_model(
|
|
FileSignatureQuery.__name__, FileSignatureQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
|
)
|
|
files_ns.schema_model(
|
|
FilePreviewQuery.__name__, FilePreviewQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
|
)
|
|
|
|
|
|
@files_ns.route("/<uuid:file_id>/image-preview")
|
|
class ImagePreviewApi(Resource):
|
|
"""Deprecated endpoint for retrieving image previews."""
|
|
|
|
@files_ns.doc("get_image_preview")
|
|
@files_ns.doc(description="Retrieve a signed image preview for a file")
|
|
@files_ns.doc(
|
|
params={
|
|
"file_id": "ID of the file to preview",
|
|
"timestamp": "Unix timestamp used in the signature",
|
|
"nonce": "Random string used in the signature",
|
|
"sign": "HMAC signature verifying the request",
|
|
}
|
|
)
|
|
@files_ns.doc(
|
|
responses={
|
|
200: "Image preview returned successfully",
|
|
400: "Missing or invalid signature parameters",
|
|
415: "Unsupported file type",
|
|
}
|
|
)
|
|
def get(self, file_id):
|
|
file_id = str(file_id)
|
|
|
|
args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
|
timestamp = args.timestamp
|
|
nonce = args.nonce
|
|
sign = args.sign
|
|
|
|
try:
|
|
public_url, generator, mimetype = FileService(db.engine).get_image_preview(
|
|
file_id=file_id,
|
|
timestamp=timestamp,
|
|
nonce=nonce,
|
|
sign=sign,
|
|
)
|
|
except services.errors.file.UnsupportedFileTypeError:
|
|
raise UnsupportedFileTypeError()
|
|
|
|
if public_url:
|
|
return redirect(public_url, code=302)
|
|
|
|
return Response(generator, mimetype=mimetype)
|
|
|
|
|
|
@files_ns.route("/<uuid:file_id>/file-preview")
|
|
class FilePreviewApi(Resource):
|
|
@files_ns.doc("get_file_preview")
|
|
@files_ns.doc(description="Download a file preview or attachment using signed parameters")
|
|
@files_ns.doc(
|
|
params={
|
|
"file_id": "ID of the file to preview",
|
|
"timestamp": "Unix timestamp used in the signature",
|
|
"nonce": "Random string used in the signature",
|
|
"sign": "HMAC signature verifying the request",
|
|
"as_attachment": "Whether to download the file as an attachment",
|
|
}
|
|
)
|
|
@files_ns.doc(
|
|
responses={
|
|
200: "File stream returned successfully",
|
|
400: "Missing or invalid signature parameters",
|
|
404: "File not found",
|
|
415: "Unsupported file type",
|
|
}
|
|
)
|
|
def get(self, file_id):
|
|
file_id = str(file_id)
|
|
|
|
args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
|
|
|
try:
|
|
public_url, generator, upload_file = FileService(db.engine).get_file_generator_by_file_id(
|
|
file_id=file_id,
|
|
timestamp=args.timestamp,
|
|
nonce=args.nonce,
|
|
sign=args.sign,
|
|
)
|
|
except services.errors.file.UnsupportedFileTypeError:
|
|
raise UnsupportedFileTypeError()
|
|
|
|
if public_url:
|
|
return redirect(public_url, code=302)
|
|
|
|
response = Response(
|
|
generator,
|
|
mimetype=upload_file.mime_type,
|
|
direct_passthrough=True,
|
|
headers={},
|
|
)
|
|
# add Accept-Ranges header for audio/video files
|
|
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"
|
|
if upload_file.size > 0:
|
|
response.headers["Content-Length"] = str(upload_file.size)
|
|
if args.as_attachment:
|
|
encoded_filename = quote(upload_file.name)
|
|
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
|
response.headers["Content-Type"] = "application/octet-stream"
|
|
|
|
enforce_download_for_html(
|
|
response,
|
|
mime_type=upload_file.mime_type,
|
|
filename=upload_file.name,
|
|
extension=upload_file.extension,
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@files_ns.route("/workspaces/<uuid:workspace_id>/webapp-logo")
|
|
class WorkspaceWebappLogoApi(Resource):
|
|
@files_ns.doc("get_workspace_webapp_logo")
|
|
@files_ns.doc(description="Fetch the custom webapp logo for a workspace")
|
|
@files_ns.doc(
|
|
params={
|
|
"workspace_id": "Workspace identifier",
|
|
}
|
|
)
|
|
@files_ns.doc(
|
|
responses={
|
|
200: "Logo returned successfully",
|
|
404: "Webapp logo not configured",
|
|
415: "Unsupported file type",
|
|
}
|
|
)
|
|
def get(self, workspace_id):
|
|
workspace_id = str(workspace_id)
|
|
|
|
custom_config = TenantService.get_custom_config(workspace_id)
|
|
webapp_logo_file_id = custom_config.get("replace_webapp_logo") if custom_config is not None else None
|
|
|
|
if not webapp_logo_file_id:
|
|
raise NotFound("webapp logo is not found")
|
|
|
|
try:
|
|
public_url, generator, mimetype = FileService(db.engine).get_public_image_preview(
|
|
webapp_logo_file_id,
|
|
)
|
|
except services.errors.file.UnsupportedFileTypeError:
|
|
raise UnsupportedFileTypeError()
|
|
|
|
if public_url:
|
|
return redirect(public_url, code=302)
|
|
|
|
return Response(generator, mimetype=mimetype)
|