diff --git a/api/.env.example b/api/.env.example
index f6f65011ea..0399f18306 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -116,6 +116,14 @@ S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
S3_REGION=your-region
S3_ADDRESS_STYLE=auto
+# Optional public base URL for objects in the bucket. When set, signed file
+# previews are served by 302-redirecting to "/" so that bytes
+# are delivered directly by the object store / CDN. Examples:
+# Cloudflare R2 custom domain: https://cdn.example.com
+# MinIO public endpoint: https://minio.example.com/your-bucket
+# Aliyun OSS public domain: https://your-bucket.oss-cn-hangzhou.aliyuncs.com
+# Leave empty to keep the default API-streamed behavior.
+S3_PUBLIC_BASE_URL=
# Workflow run and Conversation archive storage (S3-compatible)
ARCHIVE_STORAGE_ENABLED=false
diff --git a/api/configs/middleware/storage/amazon_s3_storage_config.py b/api/configs/middleware/storage/amazon_s3_storage_config.py
index 9277a335f7..e8243e92f6 100644
--- a/api/configs/middleware/storage/amazon_s3_storage_config.py
+++ b/api/configs/middleware/storage/amazon_s3_storage_config.py
@@ -43,3 +43,16 @@ class S3StorageConfig(BaseSettings):
description="Use AWS managed IAM roles for authentication instead of access/secret keys",
default=False,
)
+
+ S3_PUBLIC_BASE_URL: str | None = Field(
+ description=(
+ "Optional public base URL for objects in the bucket "
+ "(e.g., a Cloudflare R2 custom domain, MinIO public endpoint, or "
+ "OSS public domain). When set, signed file previews are served via "
+ "302 redirect to '/' so that bytes are delivered "
+ "directly by the object store / CDN instead of proxied by Dify's API. "
+ "Trailing slashes are ignored. Leave empty to keep the default "
+ "API-streamed behavior."
+ ),
+ default=None,
+ )
diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py
index a91e745f80..7cfaaba4fb 100644
--- a/api/controllers/files/image_preview.py
+++ b/api/controllers/files/image_preview.py
@@ -1,6 +1,6 @@
from urllib.parse import quote
-from flask import Response, request
+from flask import Response, redirect, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import NotFound
@@ -64,7 +64,7 @@ class ImagePreviewApi(Resource):
sign = args.sign
try:
- generator, mimetype = FileService(db.engine).get_image_preview(
+ public_url, generator, mimetype = FileService(db.engine).get_image_preview(
file_id=file_id,
timestamp=timestamp,
nonce=nonce,
@@ -73,6 +73,9 @@ class ImagePreviewApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
+ if public_url:
+ return redirect(public_url, code=302)
+
return Response(generator, mimetype=mimetype)
@@ -103,7 +106,7 @@ class FilePreviewApi(Resource):
args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
try:
- generator, upload_file = FileService(db.engine).get_file_generator_by_file_id(
+ public_url, generator, upload_file = FileService(db.engine).get_file_generator_by_file_id(
file_id=file_id,
timestamp=args.timestamp,
nonce=args.nonce,
@@ -112,6 +115,9 @@ class FilePreviewApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
+ if public_url:
+ return redirect(public_url, code=302)
+
response = Response(
generator,
mimetype=upload_file.mime_type,
@@ -175,10 +181,13 @@ class WorkspaceWebappLogoApi(Resource):
raise NotFound("webapp logo is not found")
try:
- generator, mimetype = FileService(db.engine).get_public_image_preview(
+ 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)
diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py
index 2f1e2f28bd..65d01eaeed 100644
--- a/api/controllers/files/tool_files.py
+++ b/api/controllers/files/tool_files.py
@@ -1,6 +1,6 @@
from urllib.parse import quote
-from flask import Response, request
+from flask import Response, redirect, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
@@ -57,6 +57,10 @@ class ToolFileApi(Resource):
try:
tool_file_manager = ToolFileManager()
+ public_url, tool_file = tool_file_manager.get_public_url_and_file_by_tool_file_id(file_id)
+ if public_url and tool_file:
+ return redirect(public_url, code=302)
+
stream, tool_file = tool_file_manager.get_file_generator_by_tool_file_id(
file_id,
)
diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py
index c87e8a3ae0..044ea4eda6 100644
--- a/api/core/tools/tool_file_manager.py
+++ b/api/core/tools/tool_file_manager.py
@@ -225,6 +225,23 @@ class ToolFileManager:
return stream, self._build_graph_file_reference(tool_file)
+ def get_public_url_and_file_by_tool_file_id(self, tool_file_id: str) -> tuple[str | None, File | None]:
+ """
+ Resolve a tool file to a public URL when the storage backend exposes one.
+
+ Returns (public_url, file_reference). If the backend has no public URL
+ configured, returns (None, file_reference) and callers should fall back
+ to the streaming path.
+ """
+ with session_factory.create_session() as session:
+ tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1))
+
+ if not tool_file:
+ return None, None
+
+ public_url = storage.get_public_url(tool_file.file_key)
+ return public_url, self._build_graph_file_reference(tool_file)
+
# init tool_file_parser
from graphon.file.tool_file_parser import set_tool_file_manager_factory
diff --git a/api/extensions/ext_storage.py b/api/extensions/ext_storage.py
index db5a6e4812..cc42fc05fe 100644
--- a/api/extensions/ext_storage.py
+++ b/api/extensions/ext_storage.py
@@ -119,6 +119,9 @@ class Storage:
def delete(self, filename: str):
return self.storage_runner.delete(filename)
+ def get_public_url(self, filename: str) -> str | None:
+ return self.storage_runner.get_public_url(filename)
+
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
return self.storage_runner.scan(path, files=files, directories=directories)
diff --git a/api/extensions/storage/aws_s3_storage.py b/api/extensions/storage/aws_s3_storage.py
index 978f60c9b0..956073a86a 100644
--- a/api/extensions/storage/aws_s3_storage.py
+++ b/api/extensions/storage/aws_s3_storage.py
@@ -1,5 +1,6 @@
import logging
from collections.abc import Generator
+from urllib.parse import quote
import boto3
from botocore.client import Config
@@ -17,6 +18,8 @@ class AwsS3Storage(BaseStorage):
def __init__(self):
super().__init__()
self.bucket_name = dify_config.S3_BUCKET_NAME
+ public_base_url = dify_config.S3_PUBLIC_BASE_URL
+ self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
if dify_config.S3_USE_AWS_MANAGED_IAM:
logger.info("Using AWS managed IAM role for S3")
@@ -85,3 +88,8 @@ class AwsS3Storage(BaseStorage):
def delete(self, filename: str):
self.client.delete_object(Bucket=self.bucket_name, Key=filename)
+
+ def get_public_url(self, filename: str) -> str | None:
+ if not self.public_base_url:
+ return None
+ return f"{self.public_base_url}/{quote(filename, safe='/')}"
diff --git a/api/extensions/storage/base_storage.py b/api/extensions/storage/base_storage.py
index a73d429ccd..7363d821c6 100644
--- a/api/extensions/storage/base_storage.py
+++ b/api/extensions/storage/base_storage.py
@@ -31,6 +31,17 @@ class BaseStorage(ABC):
def delete(self, filename: str):
raise NotImplementedError
+ def get_public_url(self, filename: str) -> str | None:
+ """
+ Return a publicly accessible URL for the given object, or None if the
+ backend is not configured to serve content publicly.
+
+ When set, file controllers will 302-redirect signed preview requests to
+ this URL after verifying the signature, so that the bytes themselves are
+ served by the object store / CDN instead of streamed through Dify's API.
+ """
+ return None
+
def scan(self, path, files=True, directories=False) -> list[str]:
"""
Scan files and directories in the given path.
diff --git a/api/services/file_service.py b/api/services/file_service.py
index f60afe2f19..0d6e22c58e 100644
--- a/api/services/file_service.py
+++ b/api/services/file_service.py
@@ -210,9 +210,12 @@ class FileService:
if extension.lower() not in IMAGE_EXTENSIONS:
raise UnsupportedFileTypeError()
- generator = storage.load(upload_file.key, stream=True)
+ public_url = storage.get_public_url(upload_file.key)
+ if public_url:
+ return public_url, None, upload_file.mime_type
- return generator, upload_file.mime_type
+ generator = storage.load(upload_file.key, stream=True)
+ return None, generator, upload_file.mime_type
def get_file_generator_by_file_id(self, file_id: str, timestamp: str, nonce: str, sign: str):
result = file_helpers.verify_file_signature(upload_file_id=file_id, timestamp=timestamp, nonce=nonce, sign=sign)
@@ -225,9 +228,12 @@ class FileService:
if not upload_file:
raise NotFound("File not found or signature is invalid")
- generator = storage.load(upload_file.key, stream=True)
+ public_url = storage.get_public_url(upload_file.key)
+ if public_url:
+ return public_url, None, upload_file
- return generator, upload_file
+ generator = storage.load(upload_file.key, stream=True)
+ return None, generator, upload_file
def get_public_image_preview(self, file_id: str):
with self._session_maker(expire_on_commit=False) as session:
@@ -241,9 +247,12 @@ class FileService:
if extension.lower() not in IMAGE_EXTENSIONS:
raise UnsupportedFileTypeError()
- generator = storage.load(upload_file.key)
+ public_url = storage.get_public_url(upload_file.key)
+ if public_url:
+ return public_url, None, upload_file.mime_type
- return generator, upload_file.mime_type
+ generator = storage.load(upload_file.key)
+ return None, generator, upload_file.mime_type
def get_file_content(self, file_id: str) -> str:
with self._session_maker(expire_on_commit=False) as session:
diff --git a/api/tests/unit_tests/controllers/files/test_image_preview.py b/api/tests/unit_tests/controllers/files/test_image_preview.py
index 49846b89ee..361a35750f 100644
--- a/api/tests/unit_tests/controllers/files/test_image_preview.py
+++ b/api/tests/unit_tests/controllers/files/test_image_preview.py
@@ -49,6 +49,7 @@ class TestImagePreviewApi:
generator = iter([b"img"])
mock_file_service.return_value.get_image_preview.return_value = (
+ None,
generator,
"image/png",
)
@@ -60,6 +61,30 @@ class TestImagePreviewApi:
assert response.mimetype == "image/png"
+ @patch.object(module, "FileService")
+ def test_redirects_to_public_url(self, mock_file_service):
+ module.request = fake_request(
+ {
+ "timestamp": "123",
+ "nonce": "abc",
+ "sign": "sig",
+ }
+ )
+
+ mock_file_service.return_value.get_image_preview.return_value = (
+ "https://cdn.example.com/upload_files/tenant/abc.png",
+ None,
+ "image/png",
+ )
+
+ api = module.ImagePreviewApi()
+ get_fn = unwrap(api.get)
+
+ response = get_fn("file-id")
+
+ assert response.status_code == 302
+ assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/abc.png"
+
@patch.object(module, "FileService")
def test_unsupported_file_type(self, mock_file_service):
module.request = fake_request(
@@ -98,6 +123,7 @@ class TestFilePreviewApi:
upload_file = DummyUploadFile(size=100)
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
+ None,
generator,
upload_file,
)
@@ -112,6 +138,32 @@ class TestFilePreviewApi:
assert "Accept-Ranges" not in response.headers
mock_enforce.assert_called_once()
+ @patch.object(module, "FileService")
+ def test_redirects_to_public_url(self, mock_file_service):
+ module.request = fake_request(
+ {
+ "timestamp": "123",
+ "nonce": "abc",
+ "sign": "sig",
+ "as_attachment": False,
+ }
+ )
+
+ upload_file = DummyUploadFile(size=100)
+ mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
+ "https://cdn.example.com/upload_files/tenant/abc.bin",
+ None,
+ upload_file,
+ )
+
+ api = module.FilePreviewApi()
+ get_fn = unwrap(api.get)
+
+ response = get_fn("file-id")
+
+ assert response.status_code == 302
+ assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/abc.bin"
+
@patch.object(module, "enforce_download_for_html")
@patch.object(module, "FileService")
def test_as_attachment(self, mock_file_service, mock_enforce):
@@ -132,6 +184,7 @@ class TestFilePreviewApi:
)
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
+ None,
generator,
upload_file,
)
@@ -175,6 +228,7 @@ class TestWorkspaceWebappLogoApi:
generator = iter([b"logo"])
mock_file_service.return_value.get_public_image_preview.return_value = (
+ None,
generator,
"image/png",
)
@@ -186,6 +240,24 @@ class TestWorkspaceWebappLogoApi:
assert response.mimetype == "image/png"
+ @patch.object(module, "FileService")
+ @patch.object(module.TenantService, "get_custom_config")
+ def test_redirects_to_public_url(self, mock_config, mock_file_service):
+ mock_config.return_value = {"replace_webapp_logo": "logo-id"}
+ mock_file_service.return_value.get_public_image_preview.return_value = (
+ "https://cdn.example.com/upload_files/tenant/logo.png",
+ None,
+ "image/png",
+ )
+
+ api = module.WorkspaceWebappLogoApi()
+ get_fn = unwrap(api.get)
+
+ response = get_fn("workspace-id")
+
+ assert response.status_code == 302
+ assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/logo.png"
+
@patch.object(module.TenantService, "get_custom_config")
def test_logo_not_configured(self, mock_config):
mock_config.return_value = {}
diff --git a/api/tests/unit_tests/controllers/files/test_tool_files.py b/api/tests/unit_tests/controllers/files/test_tool_files.py
index edb91c3f26..1e493f7197 100644
--- a/api/tests/unit_tests/controllers/files/test_tool_files.py
+++ b/api/tests/unit_tests/controllers/files/test_tool_files.py
@@ -50,6 +50,10 @@ class TestToolFileApi:
stream = iter([b"data"])
tool_file = DummyToolFile(size=100)
+ mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
+ None,
+ tool_file,
+ )
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
stream,
tool_file,
@@ -69,6 +73,37 @@ class TestToolFileApi:
sign="sig",
)
+ @patch.object(module, "verify_tool_file_signature", return_value=True)
+ @patch.object(module, "ToolFileManager")
+ def test_redirects_to_public_url(
+ self,
+ mock_tool_file_manager,
+ mock_verify,
+ ):
+ module.request = fake_request(
+ {
+ "timestamp": "123",
+ "nonce": "abc",
+ "sign": "sig",
+ "as_attachment": False,
+ }
+ )
+
+ tool_file = DummyToolFile(size=100)
+ mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
+ "https://cdn.example.com/tool_files/abc.txt",
+ tool_file,
+ )
+
+ api = module.ToolFileApi()
+ get_fn = unwrap(api.get)
+
+ response = get_fn("file-id", "txt")
+
+ assert response.status_code == 302
+ assert response.headers["Location"] == "https://cdn.example.com/tool_files/abc.txt"
+ mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.assert_not_called()
+
@patch.object(module, "verify_tool_file_signature", return_value=True)
@patch.object(module, "ToolFileManager")
def test_as_attachment(
@@ -91,6 +126,10 @@ class TestToolFileApi:
filename="doc.pdf",
)
+ mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
+ None,
+ tool_file,
+ )
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
stream,
tool_file,
@@ -137,6 +176,10 @@ class TestToolFileApi:
}
)
+ mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
+ None,
+ None,
+ )
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
None,
None,
@@ -164,6 +207,10 @@ class TestToolFileApi:
}
)
+ mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
+ None,
+ DummyToolFile(),
+ )
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.side_effect = Exception("boom")
api = module.ToolFileApi()
diff --git a/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py b/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py
new file mode 100644
index 0000000000..fa89d9ef0c
--- /dev/null
+++ b/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py
@@ -0,0 +1,84 @@
+from unittest.mock import Mock, patch
+
+from botocore.exceptions import ClientError
+
+from extensions.storage.aws_s3_storage import AwsS3Storage
+
+
+def _build_storage(public_base_url: str | None = None) -> AwsS3Storage:
+ with patch("extensions.storage.aws_s3_storage.dify_config", autospec=True) as mock_config:
+ mock_config.S3_BUCKET_NAME = "test-bucket"
+ mock_config.S3_PUBLIC_BASE_URL = public_base_url
+ mock_config.S3_USE_AWS_MANAGED_IAM = False
+ mock_config.S3_ACCESS_KEY = "ak"
+ mock_config.S3_SECRET_KEY = "sk"
+ mock_config.S3_ENDPOINT = "https://example.com"
+ mock_config.S3_REGION = "auto"
+ mock_config.S3_ADDRESS_STYLE = "auto"
+
+ with patch("extensions.storage.aws_s3_storage.boto3") as mock_boto3:
+ client = Mock()
+ client.head_bucket.return_value = None
+ mock_boto3.client.return_value = client
+ mock_boto3.Session.return_value.client.return_value = client
+ return AwsS3Storage()
+
+
+class TestAwsS3StoragePublicUrl:
+ def test_returns_none_when_public_base_url_unset(self):
+ storage = _build_storage(public_base_url=None)
+ assert storage.get_public_url("upload_files/tenant/abc.png") is None
+
+ def test_returns_none_when_public_base_url_empty_string(self):
+ storage = _build_storage(public_base_url="")
+ assert storage.get_public_url("upload_files/tenant/abc.png") is None
+
+ def test_composes_url_when_configured(self):
+ storage = _build_storage(public_base_url="https://cdn.example.com")
+ assert (
+ storage.get_public_url("upload_files/tenant/abc.png")
+ == "https://cdn.example.com/upload_files/tenant/abc.png"
+ )
+
+ def test_strips_trailing_slash(self):
+ storage = _build_storage(public_base_url="https://cdn.example.com/")
+ assert (
+ storage.get_public_url("upload_files/tenant/abc.png")
+ == "https://cdn.example.com/upload_files/tenant/abc.png"
+ )
+
+ def test_preserves_path_separators_in_key(self):
+ # Object key path separators must not be percent-encoded.
+ storage = _build_storage(public_base_url="https://cdn.example.com")
+ url = storage.get_public_url("a/b/c.txt")
+ assert url == "https://cdn.example.com/a/b/c.txt"
+
+ def test_quotes_unsafe_characters_in_key(self):
+ storage = _build_storage(public_base_url="https://cdn.example.com")
+ url = storage.get_public_url("upload_files/has space.png")
+ assert url == "https://cdn.example.com/upload_files/has%20space.png"
+
+
+class TestAwsS3StorageBucketCheck:
+ def test_init_handles_403_on_head_bucket(self):
+ # Regression: R2 / hardened buckets often return 403 on head_bucket; the
+ # constructor must swallow the error instead of crashing.
+ with patch("extensions.storage.aws_s3_storage.dify_config", autospec=True) as mock_config:
+ mock_config.S3_BUCKET_NAME = "test-bucket"
+ mock_config.S3_PUBLIC_BASE_URL = None
+ mock_config.S3_USE_AWS_MANAGED_IAM = False
+ mock_config.S3_ACCESS_KEY = "ak"
+ mock_config.S3_SECRET_KEY = "sk"
+ mock_config.S3_ENDPOINT = "https://example.com"
+ mock_config.S3_REGION = "auto"
+ mock_config.S3_ADDRESS_STYLE = "auto"
+
+ with patch("extensions.storage.aws_s3_storage.boto3") as mock_boto3:
+ client = Mock()
+ client.head_bucket.side_effect = ClientError(
+ {"Error": {"Code": "403", "Message": "Forbidden"}}, "HeadBucket"
+ )
+ mock_boto3.client.return_value = client
+ storage = AwsS3Storage()
+ assert storage.bucket_name == "test-bucket"
+ client.create_bucket.assert_not_called()
diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py
index 8e1b22886b..94f5c25902 100644
--- a/api/tests/unit_tests/services/test_file_service.py
+++ b/api/tests/unit_tests/services/test_file_service.py
@@ -253,15 +253,39 @@ class TestFileService:
patch("services.file_service.storage") as mock_storage,
):
mock_verify.return_value = True
+ mock_storage.get_public_url.return_value = None
mock_storage.load.return_value = iter([b"chunk1"])
# Execute
- gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
+ public_url, gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
# Assert
+ assert public_url is None
assert list(gen) == [b"chunk1"]
assert mime == "image/jpeg"
+ def test_get_image_preview_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
+ upload_file = MagicMock(spec=UploadFile)
+ upload_file.id = "file_id"
+ upload_file.extension = "jpg"
+ upload_file.mime_type = "image/jpeg"
+ upload_file.key = "upload_files/tenant/abc.jpg"
+ mock_db_session.scalar.return_value = upload_file
+
+ with (
+ patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
+ patch("services.file_service.storage") as mock_storage,
+ ):
+ mock_verify.return_value = True
+ mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/abc.jpg"
+
+ public_url, gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
+
+ assert public_url == "https://cdn.example.com/upload_files/tenant/abc.jpg"
+ assert gen is None
+ assert mime == "image/jpeg"
+ mock_storage.load.assert_not_called()
+
def test_get_image_preview_invalid_sig(self, file_service):
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
mock_verify.return_value = False
@@ -296,12 +320,33 @@ class TestFileService:
patch("services.file_service.storage") as mock_storage,
):
mock_verify.return_value = True
+ mock_storage.get_public_url.return_value = None
mock_storage.load.return_value = iter([b"chunk"])
- gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
+ public_url, gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
+ assert public_url is None
assert list(gen) == [b"chunk"]
assert file == upload_file
+ def test_get_file_generator_by_file_id_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
+ upload_file = MagicMock(spec=UploadFile)
+ upload_file.id = "file_id"
+ upload_file.key = "upload_files/tenant/abc.bin"
+ mock_db_session.scalar.return_value = upload_file
+
+ with (
+ patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
+ patch("services.file_service.storage") as mock_storage,
+ ):
+ mock_verify.return_value = True
+ mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/abc.bin"
+
+ public_url, gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
+ assert public_url == "https://cdn.example.com/upload_files/tenant/abc.bin"
+ assert gen is None
+ assert file == upload_file
+ mock_storage.load.assert_not_called()
+
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
mock_verify.return_value = False
@@ -324,11 +369,29 @@ class TestFileService:
mock_db_session.scalar.return_value = upload_file
with patch("services.file_service.storage") as mock_storage:
+ mock_storage.get_public_url.return_value = None
mock_storage.load.return_value = b"image content"
- gen, mime = file_service.get_public_image_preview("file_id")
+ public_url, gen, mime = file_service.get_public_image_preview("file_id")
+ assert public_url is None
assert gen == b"image content"
assert mime == "image/png"
+ def test_get_public_image_preview_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
+ upload_file = MagicMock(spec=UploadFile)
+ upload_file.id = "file_id"
+ upload_file.extension = "png"
+ upload_file.mime_type = "image/png"
+ upload_file.key = "upload_files/tenant/logo.png"
+ mock_db_session.scalar.return_value = upload_file
+
+ with patch("services.file_service.storage") as mock_storage:
+ mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/logo.png"
+ public_url, gen, mime = file_service.get_public_image_preview("file_id")
+ assert public_url == "https://cdn.example.com/upload_files/tenant/logo.png"
+ assert gen is None
+ assert mime == "image/png"
+ mock_storage.load.assert_not_called()
+
def test_get_public_image_preview_not_found(self, file_service, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found or signature is invalid"):
diff --git a/docker/.env.example b/docker/.env.example
index 29741474fa..89c9b59da3 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -483,6 +483,14 @@ S3_ADDRESS_STYLE=auto
# Whether to use AWS managed IAM roles for authenticating with the S3 service.
# If set to false, the access key and secret key must be provided.
S3_USE_AWS_MANAGED_IAM=false
+# Optional public base URL for objects in the bucket. When set, signed file
+# previews are served by 302-redirecting to "/" so that bytes
+# are delivered directly by the object store / CDN. Examples:
+# Cloudflare R2 custom domain: https://cdn.example.com
+# MinIO public endpoint: https://minio.example.com/your-bucket
+# Aliyun OSS public domain: https://your-bucket.oss-cn-hangzhou.aliyuncs.com
+# Leave empty to keep the default API-streamed behavior.
+S3_PUBLIC_BASE_URL=
# Workflow run and Conversation archive storage (S3-compatible)
ARCHIVE_STORAGE_ENABLED=false
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 60ba510f44..7c1ea7f475 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -136,6 +136,7 @@ x-shared-env: &shared-api-worker-env
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto}
S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false}
+ S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false}
ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-}
ARCHIVE_STORAGE_ARCHIVE_BUCKET: ${ARCHIVE_STORAGE_ARCHIVE_BUCKET:-}