diff --git a/api/.importlinter b/api/.importlinter index d7492912e7fe91..013b78bc689d9c 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -124,6 +124,24 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:web-app-runtime-query-service-boundary] +name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules +type = forbidden +source_modules = + services.web_app_runtime_query_service +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + services.feature_service + services.feature_service_gateway + sqlalchemy + werkzeug +allow_indirect_imports = True + [importlinter:contract:feature-query-service-boundary] name = Feature query application service is framework and persistence neutral type = forbidden diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 827fdce72b0b7d..54914b9bfb42b7 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,23 +1,19 @@ from typing import Any, Self from pydantic import AliasChoices, Field -from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.web import web_ns from controllers.web.wraps import WebApiResource -from enums import DeploymentEdition -from extensions.ext_database import db -from extensions.storage.storage_type import StorageType +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.helper import build_icon_url -from models.account import Tenant, TenantStatus -from models.model import App, AppMode, EndUser, IconType, Site +from libs.helper import build_icon_url, dump_response +from models.account import Tenant +from models.model import App, AppMode, EndUser, Site from services.entities.feature_entities import FeatureModel -from services.feature_service import FeatureService -from services.file_service import FileService +from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError class WebSiteResponse(ResponseModel): @@ -128,17 +124,6 @@ def from_app_site( ) -def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None: - """Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere.""" - if site.icon_type != IconType.IMAGE or not site.icon: - return None - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( - StorageType(dify_config.STORAGE_TYPE) == StorageType.S3 - ): - return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id) - return build_icon_url(site.icon_type, site.icon) - - @web_ns.route("/site") class AppSiteApi(WebApiResource): @web_ns.doc("Get App Site Info") @@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource): @web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__]) def get(self, app_model: App, end_user: EndUser): """Retrieve app site info.""" - # get site - site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) - - if site is None: - raise Forbidden() - - tenant = app_model.tenant - if tenant is None or tenant.status == TenantStatus.ARCHIVE: - raise Forbidden() - - features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True) - - return WebAppSiteResponse.from_app_site( - tenant=tenant, - app_model=app_model, - mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())), - site=site, - end_user_id=end_user.id, - features=features, - can_replace_logo=features.can_replace_logo, - icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id), - ).model_dump(mode="json") + try: + bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id) + except WebAppRuntimeUnavailableError: + raise Forbidden() from None + + return dump_response( + WebAppSiteResponse, + { + "app_id": bootstrap.app_id, + "mode": bootstrap.mode, + "end_user_id": end_user.id, + "enable_site": bootstrap.enable_site, + "site": bootstrap.site, + "plan": bootstrap.plan, + "can_replace_logo": bootstrap.can_replace_logo, + "custom_config": bootstrap.custom_config, + }, + ) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 21705761aff69f..44bfadc6e99465 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -42,10 +42,12 @@ from services.feature_query_service import FeatureQueryService from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway +from services.file_service import FileService from services.init_validation_service import InitValidationService from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner from services.setup_service import SetupService +from services.web_app_runtime_query_service import WebAppRuntimeQueryService from services.webapp_access_query_service import ( WebAppAccessQueryService, WebAppAccessUnavailableError, @@ -82,6 +84,7 @@ class ApplicationServices: app_definitions: AppDefinitionQueryService data_source_api_key_auth: DataSourceApiKeyAuthService webapp_access: WebAppAccessQueryService + web_app_runtime: WebAppRuntimeQueryService explore_banner_queries: ExploreBannerQueryService schema_definitions: SchemaDefinitionService setup: SetupService @@ -100,6 +103,8 @@ def build_application_services( ) -> ApplicationServices: installation_state = InstallationStateRepository(client=database_client) data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client) + app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client) + feature_gateway = FeatureServiceGateway() return ApplicationServices( account_activation=AccountActivationService( tokens=RegisterServiceInvitationTokenStore(), @@ -113,7 +118,7 @@ def build_application_services( ), ), app_definitions=AppDefinitionQueryService( - definitions=AppDefinitionQueryRepository(session_factory=database_client), + definitions=app_definition_repository, builtin_icon_url_prefix=( dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/" ), @@ -129,6 +134,12 @@ def build_application_services( access_mode_for_app=_get_enterprise_webapp_access_mode, is_user_allowed_for_app=_is_user_allowed_to_access_webapp, ), + web_app_runtime=WebAppRuntimeQueryService( + runtime=app_definition_repository, + file_service=FileService(database_client), + workspace_features=feature_gateway.get_workspace_features, + files_url=dify_config.FILES_URL, + ), explore_banner_queries=ExploreBannerQueryService( banners=ExploreBannerQueryRepository(client=database_client), enabled=FeatureService.is_explore_banner_enabled(), @@ -141,7 +152,7 @@ def build_application_services( setup_required=deployment_edition != DeploymentEdition.CLOUD, ), feature_queries=FeatureQueryService( - features=FeatureServiceGateway(), + features=feature_gateway, trial_models=FeatureService.get_trial_models(), app_dsl_version=CURRENT_APP_DSL_VERSION, ), diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py index 455665506005cb..0907040e787eb4 100644 --- a/api/repositories/app_definition_query_repository.py +++ b/api/repositories/app_definition_query_repository.py @@ -9,6 +9,7 @@ from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from models.account import Tenant, TenantStatus from models.agent import AgentConfigSnapshot from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config @@ -21,6 +22,27 @@ AppSiteConfiguration, AppToolIconSource, ) +from services.web_app_runtime_query_service import WebAppRuntimeRecord + + +def _map_site_configuration(site: Site) -> AppSiteConfiguration: + return AppSiteConfiguration( + title=site.title, + chat_color_theme=site.chat_color_theme, + chat_color_theme_inverted=site.chat_color_theme_inverted, + icon_type=site.icon_type.value if site.icon_type is not None else None, + icon=site.icon, + icon_background=site.icon_background, + description=site.description, + copyright=site.copyright, + privacy_policy=site.privacy_policy, + input_placeholder=site.input_placeholder, + custom_disclaimer=site.custom_disclaimer, + default_language=site.default_language, + prompt_public=site.prompt_public, + show_workflow_steps=site.show_workflow_steps, + use_icon_as_answer_icon=site.use_icon_as_answer_icon, + ) def _get_public_agent_parameter_config(app: App, *, session: Session) -> AppParameterConfig: @@ -153,21 +175,41 @@ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: if site is None: return None - return AppSiteConfiguration( - title=site.title, - chat_color_theme=site.chat_color_theme, - chat_color_theme_inverted=site.chat_color_theme_inverted, - icon_type=site.icon_type.value if site.icon_type is not None else None, - icon=site.icon, - icon_background=site.icon_background, - description=site.description, - copyright=site.copyright, - privacy_policy=site.privacy_policy, - input_placeholder=site.input_placeholder, - custom_disclaimer=site.custom_disclaimer, - default_language=site.default_language, - show_workflow_steps=site.show_workflow_steps, - use_icon_as_answer_icon=site.use_icon_as_answer_icon, + return _map_site_configuration(site) + + def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: + with self._session_factory() as session: + app = session.get(App, app_id) + if app is None: + return None + + site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1)) + if site is None: + return None + + tenant = session.get(Tenant, app.tenant_id) + if tenant is None: + return None + + app_id = app.id + tenant_id = app.tenant_id + enable_site = app.enable_site + site_configuration = _map_site_configuration(site) + plan = tenant.plan + tenant_status = tenant.status.value + tenant_custom_config_json = tenant.custom_config + mode = AppMode.value_of(app.mode).value + if tenant.status != TenantStatus.ARCHIVE: + mode = AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value + return WebAppRuntimeRecord( + app_id=app_id, + tenant_id=tenant_id, + mode=mode, + enable_site=enable_site, + site=site_configuration, + plan=plan, + tenant_status=tenant_status, + tenant_custom_config_json=tenant_custom_config_json, ) @staticmethod diff --git a/api/services/app_definition_query_service.py b/api/services/app_definition_query_service.py index 6ebd367a9619bf..6815911b119859 100644 --- a/api/services/app_definition_query_service.py +++ b/api/services/app_definition_query_service.py @@ -41,6 +41,7 @@ class AppSiteConfiguration(NamedTuple): input_placeholder: str | None custom_disclaimer: str | None default_language: str + prompt_public: bool show_workflow_steps: bool use_icon_as_answer_icon: bool diff --git a/api/services/file_service.py b/api/services/file_service.py index 3ddc81ab41f3e1..4497639eb25c6b 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -20,6 +20,7 @@ VIDEO_EXTENSIONS, ) from core.rag.extractor.extract_processor import ExtractProcessor +from enums import DeploymentEdition from extensions.ext_storage import storage from extensions.storage.storage_type import StorageType from graphon.file import helpers as file_helpers @@ -179,6 +180,13 @@ def get_file_presigned_url(self, *, file_id: str, tenant_id: str) -> str: content_type=content_type, ) + def get_icon_url(self, file_id: str, tenant_id: str) -> str: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( + StorageType(dify_config.STORAGE_TYPE) == StorageType.S3 + ): + return self.get_file_presigned_url(file_id=file_id, tenant_id=tenant_id) + return file_helpers.get_signed_file_url(upload_file_id=file_id) + def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile: if len(text_name) > 200: text_name = text_name[:200] diff --git a/api/services/web_app_runtime_query_service.py b/api/services/web_app_runtime_query_service.py new file mode 100644 index 00000000000000..559b0685d85468 --- /dev/null +++ b/api/services/web_app_runtime_query_service.py @@ -0,0 +1,102 @@ +"""Application service for building the public Web app runtime bootstrap.""" + +import json +from collections.abc import Callable, Mapping +from typing import NamedTuple, Protocol, cast + +from services.app_definition_query_service import AppSiteConfiguration +from services.entities.feature_entities import FeatureModel +from services.file_service import FileService + + +class WebAppRuntimeRecord(NamedTuple): + app_id: str + tenant_id: str + mode: str + enable_site: bool + site: AppSiteConfiguration + plan: str + tenant_status: str + # Keep this lazy: workspaces without custom branding never parsed this legacy field. + tenant_custom_config_json: str | None + + +class WebAppBootstrap(NamedTuple): + app_id: str + mode: str + enable_site: bool + site: Mapping[str, str | bool | None] + plan: str + can_replace_logo: bool + custom_config: Mapping[str, str | bool | None] | None + + +class WebAppRuntimeQuery(Protocol): + def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: ... + + +class WebAppRuntimeUnavailableError(ValueError): + """Raised when the admitted Web app can no longer be bootstrapped.""" + + +_ARCHIVED_TENANT_STATUS = "archive" + + +class WebAppRuntimeQueryService: + def __init__( + self, + *, + runtime: WebAppRuntimeQuery, + file_service: FileService, + workspace_features: Callable[[str], FeatureModel], + files_url: str, + ) -> None: + self._runtime = runtime + self._file_service = file_service + self._workspace_features = workspace_features + self._files_url = files_url + + def get_bootstrap(self, app_id: str) -> WebAppBootstrap: + record = self._runtime.get_runtime_record(app_id) + if record is None or record.tenant_status == _ARCHIVED_TENANT_STATUS: + raise WebAppRuntimeUnavailableError("Site not found") + + features = self._workspace_features(record.tenant_id) + site_icon_url = ( + self._file_service.get_icon_url(record.site.icon, record.tenant_id) + if record.site.icon_type == "image" and record.site.icon + else None + ) + + site = cast(dict[str, str | bool | None], record.site._asdict()) + site["icon_url"] = site_icon_url + if features.billing.enabled and not features.webapp_copyright_enabled: + site["copyright"] = None + site["input_placeholder"] = None + + custom_config = None + if features.can_replace_logo: + tenant_custom_config = ( + cast(Mapping[str, str | bool | None], json.loads(record.tenant_custom_config_json)) + if record.tenant_custom_config_json + else {} + ) + replace_webapp_logo = ( + f"{self._files_url}/files/workspaces/{record.tenant_id}/webapp-logo" + if tenant_custom_config.get("replace_webapp_logo") + else None + ) + custom_config = { + "remove_webapp_brand": tenant_custom_config.get("remove_webapp_brand", False), + "replace_webapp_logo": replace_webapp_logo, + } + + return WebAppBootstrap( + app_id=record.app_id, + mode=record.mode, + enable_site=record.enable_site, + site=site, + plan=record.plan, + can_replace_logo=features.can_replace_logo, + custom_config=custom_config, + ) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index 5c3ad5e199b8ab..d26965a0e99925 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -231,7 +231,7 @@ def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> Fea return features monkeypatch.setattr( - "controllers.web.site.FeatureService.get_features", + "controllers.web.human_input_form.FeatureService.get_features", mock_get_features, ) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index cdba83851fc726..e5f6f83d768a4b 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -9,10 +9,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden -from configs import dify_config from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse -from enums import DeploymentEdition -from extensions.storage.storage_type import StorageType from models import Tenant, TenantStatus from models.account import TenantCustomConfigDict from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site @@ -82,7 +79,7 @@ def _site_model(*, app_id: str) -> Site: class TestAppSiteApi: - @patch("controllers.web.site.FeatureService.get_features") + @patch("services.feature_service.FeatureService.get_features") def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) @@ -100,39 +97,6 @@ def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_ assert result["enable_site"] is True assert result["mode"] == AppMode.CHAT - @patch("controllers.web.site.FileService.get_file_presigned_url") - @patch("controllers.web.site.FeatureService.get_features") - def test_image_icon_uses_s3_presigned_url( - self, - mock_features: MagicMock, - mock_get_file_presigned_url: MagicMock, - app: Flask, - db_session_with_containers: Session, - ) -> None: - app.config["RESTX_MASK_HEADER"] = "X-Fields" - tenant = _create_tenant(db_session_with_containers) - app_model = _create_app(db_session_with_containers, tenant.id) - site = _create_site(db_session_with_containers, app_model.id) - site.icon_type = "image" - site.icon = "11111111-1111-4111-8111-111111111111" - db_session_with_containers.commit() - end_user = _end_user(tenant.id, app_model.id) - mock_features.return_value = FeatureModel(can_replace_logo=False) - mock_get_file_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" - - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - app.test_request_context("/site"), - ): - result = AppSiteApi().get(app_model, end_user) - - assert result["site"]["icon_url"] == "https://s3.example.com/icon.png?signature=test" - mock_get_file_presigned_url.assert_called_once_with( - file_id="11111111-1111-4111-8111-111111111111", - tenant_id=tenant.id, - ) - def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index a6ff026f6d5921..38d7eb7e678f68 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -312,6 +312,7 @@ def test_get_site_configuration_queries_authenticated_app( input_placeholder="Ask anything", custom_disclaimer=None, default_language="en-US", + prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, ) diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 985c1e41ba9ada..5d9ba9e42e41a9 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -14,7 +14,6 @@ from werkzeug.exceptions import Forbidden import controllers.web.human_input_form as human_input_module -import controllers.web.site as site_module from controllers.web.error import WebFormRateLimitExceededError from core.workflow.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource from core.workflow.nodes.human_input.enums import ValueSourceType @@ -139,7 +138,7 @@ def get_definition(self): monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) monkeypatch.setattr( - site_module.FeatureService, + human_input_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) @@ -259,7 +258,7 @@ def get_definition(self): def mock_get_features(tenant_id: str, exclude_vector_space: bool = False): return FeatureModel(can_replace_logo=True) - monkeypatch.setattr(site_module.FeatureService, "get_features", mock_get_features) + monkeypatch.setattr(human_input_module.FeatureService, "get_features", mock_get_features) with app.test_request_context("/api/form/human_input/token-1", method="GET"): response = HumanInputFormApi().get("token-1") @@ -360,7 +359,7 @@ def get_definition(self): monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) monkeypatch.setattr( - site_module.FeatureService, + human_input_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index 011d6b6a51e6f2..8440796984ad48 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -1,106 +1,82 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch -from configs import dify_config -from controllers.web import site as site_module -from enums import DeploymentEdition -from extensions.storage.storage_type import StorageType -from models.model import AppMode, IconType, Site -from services.entities.feature_entities import FeatureModel - - -def test_app_site_api_returns_legacy_agent_compatible_mode() -> None: - app_model = MagicMock() - app_model.id = "app-id" - app_model.tenant_id = "tenant-id" - app_model.tenant = MagicMock(id="tenant-id", status="normal") - app_model.mode_compatible_with_agent_with_session.return_value = AppMode.AGENT_CHAT - end_user = MagicMock(id="end-user-id") - site = Site() - response = MagicMock() - response.model_dump.return_value = {"mode": AppMode.AGENT_CHAT} - - with ( - patch.object(site_module, "db") as mock_db, - patch.object(site_module.FeatureService, "get_features", return_value=FeatureModel(can_replace_logo=False)), - patch.object(site_module, "_build_site_icon_url", return_value=None), - patch.object(site_module.WebAppSiteResponse, "from_app_site", return_value=response) as mock_from_app_site, - ): - mock_db.session.scalar.return_value = site - result = site_module.AppSiteApi().get(app_model, end_user) +import pytest +from werkzeug.exceptions import Forbidden - assert result["mode"] == AppMode.AGENT_CHAT - app_model.mode_compatible_with_agent_with_session.assert_called_once_with(session=mock_db.session()) - mock_from_app_site.assert_called_once_with( - tenant=app_model.tenant, - app_model=app_model, - mode=AppMode.AGENT_CHAT, - site=site, - end_user_id=end_user.id, - features=FeatureModel(can_replace_logo=False), - can_replace_logo=False, - icon_url=None, +from controllers.web import site as site_module +from services.app_definition_query_service import AppSiteConfiguration +from services.web_app_runtime_query_service import WebAppBootstrap, WebAppRuntimeUnavailableError + + +def _bootstrap() -> WebAppBootstrap: + site = AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type="image", + icon="file-1", + icon_background="#ffffff", + description="Description", + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + custom_disclaimer="Disclaimer", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, ) - - -def test_build_site_icon_url_uses_s3_presigned_url() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", + return WebAppBootstrap( + app_id="app-id", + mode="agent-chat", + enable_site=True, + site={**site._asdict(), "icon_url": "https://files.example.com/icon.png"}, + plan="pro", + can_replace_logo=True, + custom_config={ + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-id/webapp-logo", + }, ) - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - patch.object(site_module, "db") as mock_db, - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url") as mock_build_icon_url, - ): - mock_file_service.return_value.get_file_presigned_url.return_value = ( - "https://s3.example.com/icon.png?signature=test" - ) - - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") - - assert result == "https://s3.example.com/icon.png?signature=test" - mock_file_service.assert_called_once_with(mock_db.engine) - mock_file_service.return_value.get_file_presigned_url.assert_called_once_with( - file_id="11111111-1111-4111-8111-111111111111", - tenant_id="tenant-id", - ) - mock_build_icon_url.assert_not_called() - -def test_build_site_icon_url_keeps_preview_url_for_self_hosted_s3() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", - ) +def test_app_site_api_queries_the_admitted_app_runtime() -> None: + app_model = MagicMock(id="app-id") + end_user = MagicMock(id="end-user-id") + web_app_runtime = MagicMock() + web_app_runtime.get_bootstrap.return_value = _bootstrap() - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + with patch.object( + site_module, + "application_services", + return_value=SimpleNamespace(web_app_runtime=web_app_runtime), ): - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") + result = site_module.AppSiteApi().get(app_model, end_user) - assert result == "https://api.example.com/files/icon/file-preview" - mock_file_service.assert_not_called() + assert result["app_id"] == "app-id" + assert result["mode"] == "agent-chat" + assert result["end_user_id"] == "end-user-id" + assert result["site"]["prompt_public"] is True + assert result["site"]["icon_url"] == "https://files.example.com/icon.png" + assert result["model_config"] is None + assert result["custom_config"] == { + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-id/webapp-logo", + } + web_app_runtime.get_bootstrap.assert_called_once_with("app-id") -def test_build_site_icon_url_keeps_preview_url_for_non_s3_storage() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", - ) +def test_app_site_api_maps_unavailable_runtime_to_forbidden() -> None: + web_app_runtime = MagicMock() + web_app_runtime.get_bootstrap.side_effect = WebAppRuntimeUnavailableError with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.LOCAL), - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + patch.object( + site_module, + "application_services", + return_value=SimpleNamespace(web_app_runtime=web_app_runtime), + ), + pytest.raises(Forbidden), ): - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") - - assert result == "https://api.example.com/files/icon/file-preview" - mock_file_service.assert_not_called() + site_module.AppSiteApi().get(MagicMock(id="app-id"), MagicMock(id="end-user-id")) diff --git a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py index c47943c80df73e..cc9c851bddf519 100644 --- a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py +++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.tools.entities.tool_entities import ApiProviderSchemaType -from models.account import Account +from models.account import Account, Tenant, TenantStatus from models.enums import CustomizeTokenStrategy, TagType from models.model import App, AppMode, AppModelConfig, IconType, Site, Tag, TagBinding from models.tools import ApiToolProvider @@ -16,6 +16,7 @@ AppSiteConfiguration, AppToolIconSource, ) +from services.web_app_runtime_query_service import WebAppRuntimeRecord _APP_ID = "11111111-1111-1111-1111-111111111111" _TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -341,11 +342,97 @@ def test_get_site_configuration_maps_site_fields(sqlite_session_factory: session input_placeholder="Ask anything", custom_disclaimer="Disclaimer", default_language="en-US", + prompt_public=True, show_workflow_steps=False, use_icon_as_answer_icon=True, ) +@pytest.mark.parametrize( + ("tenant_status", "expected_mode"), + [ + (TenantStatus.NORMAL, AppMode.AGENT_CHAT.value), + (TenantStatus.ARCHIVE, AppMode.CHAT.value), + ], +) +def test_get_runtime_record_maps_app_tenant_site_and_compatible_mode( + sqlite_session_factory: sessionmaker[Session], + tenant_status: TenantStatus, + expected_mode: str, +) -> None: + tenant_custom_config = '{"remove_webapp_brand":true,"replace_webapp_logo":"logo-file"}' + with sqlite_session_factory.begin() as session: + tenant = Tenant( + name="Test Tenant", + plan="pro", + status=tenant_status, + custom_config=tenant_custom_config, + ) + tenant.id = _TENANT_ID + session.add(tenant) + app = _persist_app(session) + app_model_config = AppModelConfig( + app_id=app.id, + agent_mode=json.dumps({"enabled": True, "strategy": "react"}), + ) + session.add(app_model_config) + session.flush() + app.app_model_config_id = app_model_config.id + session.add( + Site( + app_id=app.id, + title="Test Site", + icon_type=IconType.IMAGE, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + default_language="en-US", + chat_color_theme="light", + chat_color_theme_inverted=False, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + ) + + repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory) + + assert repository.get_runtime_record(_APP_ID) == WebAppRuntimeRecord( + app_id=_APP_ID, + tenant_id=_TENANT_ID, + mode=expected_mode, + enable_site=True, + site=AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type=IconType.IMAGE.value, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + description=None, + copyright=None, + privacy_policy=None, + input_placeholder=None, + custom_disclaimer="", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ), + plan="pro", + tenant_status=tenant_status.value, + tenant_custom_config_json=tenant_custom_config, + ) + + +def test_get_runtime_record_returns_none_for_missing_app( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory) + + assert repository.get_runtime_record(_APP_ID) is None + + def _tool(provider_type: str, provider_id: str, tool_name: str) -> dict[str, object]: return { "provider_type": provider_type, diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 0994abecf65296..f345a2ec0124db 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import NotFound from configs import dify_config +from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models.base import TypeBase from models.enums import CreatorUserRole @@ -296,6 +297,40 @@ def test_get_file_presigned_url_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") + def test_get_icon_url_uses_direct_storage_url_for_cloud_s3(self, file_service: FileService): + with ( + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), + patch.object(file_service, "get_file_presigned_url", return_value="direct-url") as get_presigned_url, + ): + result = file_service.get_icon_url("file_id", "tenant_id") + + assert result == "direct-url" + get_presigned_url.assert_called_once_with(file_id="file_id", tenant_id="tenant_id") + + @pytest.mark.parametrize( + ("deployment_edition", "storage_type"), + [ + (DeploymentEdition.COMMUNITY, StorageType.S3), + (DeploymentEdition.CLOUD, StorageType.LOCAL), + ], + ) + def test_get_icon_url_uses_preview_url_outside_cloud_s3( + self, + file_service: FileService, + deployment_edition: DeploymentEdition, + storage_type: StorageType, + ): + with ( + patch.object(dify_config, "DEPLOYMENT_EDITION", deployment_edition), + patch.object(dify_config, "STORAGE_TYPE", storage_type), + patch("services.file_service.file_helpers.get_signed_file_url", return_value="preview-url") as get_url, + ): + result = file_service.get_icon_url("file_id", "tenant_id") + + assert result == "preview-url" + get_url.assert_called_once_with(upload_file_id="file_id") + def test_upload_text_success(self, file_service: FileService, db_session: Session): # Setup text = "sample text" diff --git a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py new file mode 100644 index 00000000000000..5464c33ec91ffe --- /dev/null +++ b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py @@ -0,0 +1,144 @@ +from unittest.mock import MagicMock, create_autospec + +import pytest + +from services.app_definition_query_service import AppSiteConfiguration +from services.entities.feature_entities import FeatureModel +from services.file_service import FileService +from services.web_app_runtime_query_service import ( + WebAppBootstrap, + WebAppRuntimeQuery, + WebAppRuntimeQueryService, + WebAppRuntimeRecord, + WebAppRuntimeUnavailableError, +) + +_FILES_URL = "https://files.example.com" + + +@pytest.fixture +def workspace_features() -> MagicMock: + return MagicMock(return_value=FeatureModel()) + + +def _site_configuration() -> AppSiteConfiguration: + return AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type="image", + icon="file-1", + icon_background="#ffffff", + description="Description", + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + custom_disclaimer="Disclaimer", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + +def _runtime_record( + *, + mode: str = "agent-chat", + tenant_status: str = "normal", + tenant_custom_config_json: str | None = '{"remove_webapp_brand":true,"replace_webapp_logo":"file-2"}', +) -> WebAppRuntimeRecord: + return WebAppRuntimeRecord( + app_id="app-1", + tenant_id="tenant-1", + mode=mode, + enable_site=True, + site=_site_configuration(), + plan="pro", + tenant_status=tenant_status, + tenant_custom_config_json=tenant_custom_config_json, + ) + + +def _service( + runtime: MagicMock, + *, + file_service: MagicMock | None = None, + workspace_features: MagicMock | None = None, +) -> WebAppRuntimeQueryService: + if file_service is None: + file_service = MagicMock(spec=FileService) + file_service.get_icon_url.return_value = None + if workspace_features is None: + workspace_features = MagicMock(return_value=FeatureModel()) + return WebAppRuntimeQueryService( + runtime=runtime, + file_service=file_service, + workspace_features=workspace_features, + files_url=_FILES_URL, + ) + + +@pytest.mark.parametrize("record", [None, _runtime_record(tenant_status="archive")]) +def test_get_bootstrap_rejects_unavailable_runtime(record: WebAppRuntimeRecord | None) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + runtime.get_runtime_record.return_value = record + + with pytest.raises(WebAppRuntimeUnavailableError, match="Site not found"): + _service(runtime).get_bootstrap("app-1") + + +def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( + workspace_features: MagicMock, +) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + record = _runtime_record() + features = FeatureModel(can_replace_logo=True, webapp_copyright_enabled=False) + features.billing.enabled = True + events: list[str] = [] + runtime.get_runtime_record.side_effect = lambda _app_id: events.append("record") or record + workspace_features.side_effect = lambda _tenant_id, **_kwargs: events.append("features") or features + file_service = MagicMock(spec=FileService) + file_service.get_icon_url.side_effect = lambda *_args, **_kwargs: events.append("icon") or "https://icon" + + result = _service( + runtime, + file_service=file_service, + workspace_features=workspace_features, + ).get_bootstrap("app-1") + + assert result == WebAppBootstrap( + app_id="app-1", + mode="agent-chat", + enable_site=True, + site={ + **record.site._asdict(), + "copyright": None, + "input_placeholder": None, + "icon_url": "https://icon", + }, + plan="pro", + can_replace_logo=True, + custom_config={ + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-1/webapp-logo", + }, + ) + assert events == ["record", "features", "icon"] + workspace_features.assert_called_once_with("tenant-1") + file_service.get_icon_url.assert_called_once_with("file-1", "tenant-1") + + +def test_get_bootstrap_skips_legacy_custom_config_when_branding_is_not_allowed( + workspace_features: MagicMock, +) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + record = _runtime_record(tenant_custom_config_json="not-json") + runtime.get_runtime_record.return_value = record + + workspace_features.return_value = FeatureModel(can_replace_logo=False) + + result = _service(runtime, workspace_features=workspace_features).get_bootstrap("app-1") + + assert result.site == {**record.site._asdict(), "icon_url": None} + assert result.can_replace_logo is False + assert result.custom_config is None