mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +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>
284 lines
8.4 KiB
Python
284 lines
8.4 KiB
Python
import types
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
import controllers.files.image_preview as module
|
|
|
|
|
|
def unwrap(func):
|
|
while hasattr(func, "__wrapped__"):
|
|
func = func.__wrapped__
|
|
return func
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_db():
|
|
"""
|
|
Replace Flask-SQLAlchemy db with a plain object
|
|
to avoid touching Flask app context entirely.
|
|
"""
|
|
fake_db = types.SimpleNamespace(engine=object())
|
|
module.db = fake_db
|
|
|
|
|
|
class DummyUploadFile:
|
|
def __init__(self, mime_type="text/plain", size=10, name="test.txt", extension="txt"):
|
|
self.mime_type = mime_type
|
|
self.size = size
|
|
self.name = name
|
|
self.extension = extension
|
|
|
|
|
|
def fake_request(args: dict):
|
|
"""Return a fake request object (NOT a Flask LocalProxy)."""
|
|
return types.SimpleNamespace(args=types.SimpleNamespace(to_dict=lambda flat=True: args))
|
|
|
|
|
|
class TestImagePreviewApi:
|
|
@patch.object(module, "FileService")
|
|
def test_success(self, mock_file_service):
|
|
module.request = fake_request(
|
|
{
|
|
"timestamp": "123",
|
|
"nonce": "abc",
|
|
"sign": "sig",
|
|
}
|
|
)
|
|
|
|
generator = iter([b"img"])
|
|
mock_file_service.return_value.get_image_preview.return_value = (
|
|
None,
|
|
generator,
|
|
"image/png",
|
|
)
|
|
|
|
api = module.ImagePreviewApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
response = get_fn("file-id")
|
|
|
|
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(
|
|
{
|
|
"timestamp": "123",
|
|
"nonce": "abc",
|
|
"sign": "sig",
|
|
}
|
|
)
|
|
|
|
mock_file_service.return_value.get_image_preview.side_effect = (
|
|
module.services.errors.file.UnsupportedFileTypeError()
|
|
)
|
|
|
|
api = module.ImagePreviewApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
with pytest.raises(module.UnsupportedFileTypeError):
|
|
get_fn("file-id")
|
|
|
|
|
|
class TestFilePreviewApi:
|
|
@patch.object(module, "enforce_download_for_html")
|
|
@patch.object(module, "FileService")
|
|
def test_basic_stream(self, mock_file_service, mock_enforce):
|
|
module.request = fake_request(
|
|
{
|
|
"timestamp": "123",
|
|
"nonce": "abc",
|
|
"sign": "sig",
|
|
"as_attachment": False,
|
|
}
|
|
)
|
|
|
|
generator = iter([b"data"])
|
|
upload_file = DummyUploadFile(size=100)
|
|
|
|
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
|
|
None,
|
|
generator,
|
|
upload_file,
|
|
)
|
|
|
|
api = module.FilePreviewApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
response = get_fn("file-id")
|
|
|
|
assert response.mimetype == "application/octet-stream"
|
|
assert response.headers["Content-Length"] == "100"
|
|
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):
|
|
module.request = fake_request(
|
|
{
|
|
"timestamp": "123",
|
|
"nonce": "abc",
|
|
"sign": "sig",
|
|
"as_attachment": True,
|
|
}
|
|
)
|
|
|
|
generator = iter([b"data"])
|
|
upload_file = DummyUploadFile(
|
|
mime_type="application/pdf",
|
|
name="doc.pdf",
|
|
extension="pdf",
|
|
)
|
|
|
|
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
|
|
None,
|
|
generator,
|
|
upload_file,
|
|
)
|
|
|
|
api = module.FilePreviewApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
response = get_fn("file-id")
|
|
|
|
assert response.headers["Content-Disposition"].startswith("attachment")
|
|
assert response.headers["Content-Type"] == "application/octet-stream"
|
|
mock_enforce.assert_called_once()
|
|
|
|
@patch.object(module, "FileService")
|
|
def test_unsupported_file_type(self, mock_file_service):
|
|
module.request = fake_request(
|
|
{
|
|
"timestamp": "123",
|
|
"nonce": "abc",
|
|
"sign": "sig",
|
|
"as_attachment": False,
|
|
}
|
|
)
|
|
|
|
mock_file_service.return_value.get_file_generator_by_file_id.side_effect = (
|
|
module.services.errors.file.UnsupportedFileTypeError()
|
|
)
|
|
|
|
api = module.FilePreviewApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
with pytest.raises(module.UnsupportedFileTypeError):
|
|
get_fn("file-id")
|
|
|
|
|
|
class TestWorkspaceWebappLogoApi:
|
|
@patch.object(module, "FileService")
|
|
@patch.object(module.TenantService, "get_custom_config")
|
|
def test_success(self, mock_config, mock_file_service):
|
|
mock_config.return_value = {"replace_webapp_logo": "logo-id"}
|
|
generator = iter([b"logo"])
|
|
|
|
mock_file_service.return_value.get_public_image_preview.return_value = (
|
|
None,
|
|
generator,
|
|
"image/png",
|
|
)
|
|
|
|
api = module.WorkspaceWebappLogoApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
response = get_fn("workspace-id")
|
|
|
|
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 = {}
|
|
|
|
api = module.WorkspaceWebappLogoApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
with pytest.raises(NotFound):
|
|
get_fn("workspace-id")
|
|
|
|
@patch.object(module, "FileService")
|
|
@patch.object(module.TenantService, "get_custom_config")
|
|
def test_unsupported_file_type(self, mock_config, mock_file_service):
|
|
mock_config.return_value = {"replace_webapp_logo": "logo-id"}
|
|
mock_file_service.return_value.get_public_image_preview.side_effect = (
|
|
module.services.errors.file.UnsupportedFileTypeError()
|
|
)
|
|
|
|
api = module.WorkspaceWebappLogoApi()
|
|
get_fn = unwrap(api.get)
|
|
|
|
with pytest.raises(module.UnsupportedFileTypeError):
|
|
get_fn("workspace-id")
|