From f891c67eca7228410e2c2544619f766152a43150 Mon Sep 17 00:00:00 2001 From: Cluas Date: Mon, 8 Sep 2025 14:10:55 +0800 Subject: [PATCH 01/10] feat: add MCP server headers support #22718 (#24760) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Novice --- .../console/workspace/tool_providers.py | 7 + api/core/tools/entities/api_entities.py | 8 + api/core/tools/mcp_tool/provider.py | 2 +- ...20211f18133_add_headers_to_mcp_provider.py | 27 ++++ api/models/tools.py | 58 +++++++ .../tools/mcp_tools_manage_service.py | 71 ++++++++- api/services/tools/tools_transform_service.py | 4 + .../tools/test_mcp_tools_manage_service.py | 39 ++++- .../components/tools/mcp/headers-input.tsx | 143 ++++++++++++++++++ web/app/components/tools/mcp/modal.tsx | 45 +++++- web/app/components/tools/types.ts | 3 + web/i18n/en-US/tools.ts | 12 +- web/i18n/ja-JP/tools.ts | 40 ++--- web/i18n/zh-Hans/tools.ts | 12 +- web/service/use-tools.ts | 2 + 15 files changed, 441 insertions(+), 32 deletions(-) create mode 100644 api/migrations/versions/2025_09_08_1007-c20211f18133_add_headers_to_mcp_provider.py create mode 100644 web/app/components/tools/mcp/headers-input.tsx diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index d9f2e45ddf..a6bc1c37e9 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -865,6 +865,7 @@ class ToolProviderMCPApi(Resource): parser.add_argument( "sse_read_timeout", type=float, required=False, nullable=False, location="json", default=300 ) + parser.add_argument("headers", type=dict, required=False, nullable=True, location="json", default={}) args = parser.parse_args() user = current_user if not is_valid_url(args["server_url"]): @@ -881,6 +882,7 @@ class ToolProviderMCPApi(Resource): server_identifier=args["server_identifier"], timeout=args["timeout"], sse_read_timeout=args["sse_read_timeout"], + headers=args["headers"], ) ) @@ -898,6 +900,7 @@ class ToolProviderMCPApi(Resource): parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json") parser.add_argument("timeout", type=float, required=False, nullable=True, location="json") parser.add_argument("sse_read_timeout", type=float, required=False, nullable=True, location="json") + parser.add_argument("headers", type=dict, required=False, nullable=True, location="json") args = parser.parse_args() if not is_valid_url(args["server_url"]): if "[__HIDDEN__]" in args["server_url"]: @@ -915,6 +918,7 @@ class ToolProviderMCPApi(Resource): server_identifier=args["server_identifier"], timeout=args.get("timeout"), sse_read_timeout=args.get("sse_read_timeout"), + headers=args.get("headers"), ) return {"result": "success"} @@ -951,6 +955,9 @@ class ToolMCPAuthApi(Resource): authed=False, authorization_code=args["authorization_code"], for_list=True, + headers=provider.decrypted_headers, + timeout=provider.timeout, + sse_read_timeout=provider.sse_read_timeout, ): MCPToolManageService.update_mcp_provider_credentials( mcp_provider=provider, diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 187406fc2d..ca3be26ff9 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -43,6 +43,10 @@ class ToolProviderApiEntity(BaseModel): server_url: Optional[str] = Field(default="", description="The server url of the tool") updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp())) server_identifier: Optional[str] = Field(default="", description="The server identifier of the MCP tool") + timeout: Optional[float] = Field(default=30.0, description="The timeout of the MCP tool") + sse_read_timeout: Optional[float] = Field(default=300.0, description="The SSE read timeout of the MCP tool") + masked_headers: Optional[dict[str, str]] = Field(default=None, description="The masked headers of the MCP tool") + original_headers: Optional[dict[str, str]] = Field(default=None, description="The original headers of the MCP tool") @field_validator("tools", mode="before") @classmethod @@ -65,6 +69,10 @@ class ToolProviderApiEntity(BaseModel): if self.type == ToolProviderType.MCP: optional_fields.update(self.optional_field("updated_at", self.updated_at)) optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) + optional_fields.update(self.optional_field("timeout", self.timeout)) + optional_fields.update(self.optional_field("sse_read_timeout", self.sse_read_timeout)) + optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) + optional_fields.update(self.optional_field("original_headers", self.original_headers)) return { "id": self.id, "author": self.author, diff --git a/api/core/tools/mcp_tool/provider.py b/api/core/tools/mcp_tool/provider.py index dd9d3a137f..5f6eb045ab 100644 --- a/api/core/tools/mcp_tool/provider.py +++ b/api/core/tools/mcp_tool/provider.py @@ -94,7 +94,7 @@ class MCPToolProviderController(ToolProviderController): provider_id=db_provider.server_identifier or "", tenant_id=db_provider.tenant_id or "", server_url=db_provider.decrypted_server_url, - headers={}, # TODO: get headers from db provider + headers=db_provider.decrypted_headers or {}, timeout=db_provider.timeout, sse_read_timeout=db_provider.sse_read_timeout, ) diff --git a/api/migrations/versions/2025_09_08_1007-c20211f18133_add_headers_to_mcp_provider.py b/api/migrations/versions/2025_09_08_1007-c20211f18133_add_headers_to_mcp_provider.py new file mode 100644 index 0000000000..99d47478f3 --- /dev/null +++ b/api/migrations/versions/2025_09_08_1007-c20211f18133_add_headers_to_mcp_provider.py @@ -0,0 +1,27 @@ +"""add_headers_to_mcp_provider + +Revision ID: c20211f18133 +Revises: 8d289573e1da +Create Date: 2025-08-29 10:07:54.163626 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c20211f18133' +down_revision = 'b95962a3885c' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add encrypted_headers column to tool_mcp_providers table + op.add_column('tool_mcp_providers', sa.Column('encrypted_headers', sa.Text(), nullable=True)) + + +def downgrade(): + # Remove encrypted_headers column from tool_mcp_providers table + op.drop_column('tool_mcp_providers', 'encrypted_headers') diff --git a/api/models/tools.py b/api/models/tools.py index 09c8cd4002..96ad76eae5 100644 --- a/api/models/tools.py +++ b/api/models/tools.py @@ -280,6 +280,8 @@ class MCPToolProvider(Base): ) timeout: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("30")) sse_read_timeout: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("300")) + # encrypted headers for MCP server requests + encrypted_headers: Mapped[str | None] = mapped_column(sa.Text, nullable=True) def load_user(self) -> Account | None: return db.session.query(Account).where(Account.id == self.user_id).first() @@ -310,6 +312,62 @@ class MCPToolProvider(Base): def decrypted_server_url(self) -> str: return encrypter.decrypt_token(self.tenant_id, self.server_url) + @property + def decrypted_headers(self) -> dict[str, Any]: + """Get decrypted headers for MCP server requests.""" + from core.entities.provider_entities import BasicProviderConfig + from core.helper.provider_cache import NoOpProviderCredentialCache + from core.tools.utils.encryption import create_provider_encrypter + + try: + if not self.encrypted_headers: + return {} + + headers_data = json.loads(self.encrypted_headers) + + # Create dynamic config for all headers as SECRET_INPUT + config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers_data] + + encrypter_instance, _ = create_provider_encrypter( + tenant_id=self.tenant_id, + config=config, + cache=NoOpProviderCredentialCache(), + ) + + result = encrypter_instance.decrypt(headers_data) + return result + except Exception: + return {} + + @property + def masked_headers(self) -> dict[str, Any]: + """Get masked headers for frontend display.""" + from core.entities.provider_entities import BasicProviderConfig + from core.helper.provider_cache import NoOpProviderCredentialCache + from core.tools.utils.encryption import create_provider_encrypter + + try: + if not self.encrypted_headers: + return {} + + headers_data = json.loads(self.encrypted_headers) + + # Create dynamic config for all headers as SECRET_INPUT + config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers_data] + + encrypter_instance, _ = create_provider_encrypter( + tenant_id=self.tenant_id, + config=config, + cache=NoOpProviderCredentialCache(), + ) + + # First decrypt, then mask + decrypted_headers = encrypter_instance.decrypt(headers_data) + result = encrypter_instance.mask_tool_credentials(decrypted_headers) + return result + except Exception: + return {} + @property def masked_server_url(self) -> str: def mask_url(url: str, mask_char: str = "*") -> str: diff --git a/api/services/tools/mcp_tools_manage_service.py b/api/services/tools/mcp_tools_manage_service.py index b557d2155a..7e301c9bac 100644 --- a/api/services/tools/mcp_tools_manage_service.py +++ b/api/services/tools/mcp_tools_manage_service.py @@ -1,7 +1,7 @@ import hashlib import json from datetime import datetime -from typing import Any +from typing import Any, cast from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError @@ -27,6 +27,36 @@ class MCPToolManageService: Service class for managing mcp tools. """ + @staticmethod + def _encrypt_headers(headers: dict[str, str], tenant_id: str) -> dict[str, str]: + """ + Encrypt headers using ProviderConfigEncrypter with all headers as SECRET_INPUT. + + Args: + headers: Dictionary of headers to encrypt + tenant_id: Tenant ID for encryption + + Returns: + Dictionary with all headers encrypted + """ + if not headers: + return {} + + from core.entities.provider_entities import BasicProviderConfig + from core.helper.provider_cache import NoOpProviderCredentialCache + from core.tools.utils.encryption import create_provider_encrypter + + # Create dynamic config for all headers as SECRET_INPUT + config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers] + + encrypter_instance, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=config, + cache=NoOpProviderCredentialCache(), + ) + + return cast(dict[str, str], encrypter_instance.encrypt(headers)) + @staticmethod def get_mcp_provider_by_provider_id(provider_id: str, tenant_id: str) -> MCPToolProvider: res = ( @@ -61,6 +91,7 @@ class MCPToolManageService: server_identifier: str, timeout: float, sse_read_timeout: float, + headers: dict[str, str] | None = None, ) -> ToolProviderApiEntity: server_url_hash = hashlib.sha256(server_url.encode()).hexdigest() existing_provider = ( @@ -83,6 +114,12 @@ class MCPToolManageService: if existing_provider.server_identifier == server_identifier: raise ValueError(f"MCP tool {server_identifier} already exists") encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url) + # Encrypt headers + encrypted_headers = None + if headers: + encrypted_headers_dict = MCPToolManageService._encrypt_headers(headers, tenant_id) + encrypted_headers = json.dumps(encrypted_headers_dict) + mcp_tool = MCPToolProvider( tenant_id=tenant_id, name=name, @@ -95,6 +132,7 @@ class MCPToolManageService: server_identifier=server_identifier, timeout=timeout, sse_read_timeout=sse_read_timeout, + encrypted_headers=encrypted_headers, ) db.session.add(mcp_tool) db.session.commit() @@ -118,9 +156,21 @@ class MCPToolManageService: mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) server_url = mcp_provider.decrypted_server_url authed = mcp_provider.authed + headers = mcp_provider.decrypted_headers + timeout = mcp_provider.timeout + sse_read_timeout = mcp_provider.sse_read_timeout try: - with MCPClient(server_url, provider_id, tenant_id, authed=authed, for_list=True) as mcp_client: + with MCPClient( + server_url, + provider_id, + tenant_id, + authed=authed, + for_list=True, + headers=headers, + timeout=timeout, + sse_read_timeout=sse_read_timeout, + ) as mcp_client: tools = mcp_client.list_tools() except MCPAuthError: raise ValueError("Please auth the tool first") @@ -172,6 +222,7 @@ class MCPToolManageService: server_identifier: str, timeout: float | None = None, sse_read_timeout: float | None = None, + headers: dict[str, str] | None = None, ): mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) @@ -207,6 +258,13 @@ class MCPToolManageService: mcp_provider.timeout = timeout if sse_read_timeout is not None: mcp_provider.sse_read_timeout = sse_read_timeout + if headers is not None: + # Encrypt headers + if headers: + encrypted_headers_dict = MCPToolManageService._encrypt_headers(headers, tenant_id) + mcp_provider.encrypted_headers = json.dumps(encrypted_headers_dict) + else: + mcp_provider.encrypted_headers = None db.session.commit() except IntegrityError as e: db.session.rollback() @@ -242,6 +300,12 @@ class MCPToolManageService: @classmethod def _re_connect_mcp_provider(cls, server_url: str, provider_id: str, tenant_id: str): + # Get the existing provider to access headers and timeout settings + mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) + headers = mcp_provider.decrypted_headers + timeout = mcp_provider.timeout + sse_read_timeout = mcp_provider.sse_read_timeout + try: with MCPClient( server_url, @@ -249,6 +313,9 @@ class MCPToolManageService: tenant_id, authed=False, for_list=True, + headers=headers, + timeout=timeout, + sse_read_timeout=sse_read_timeout, ) as mcp_client: tools = mcp_client.list_tools() return { diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index d084b377ec..f5fc7f951f 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -237,6 +237,10 @@ class ToolTransformService: label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name), description=I18nObject(en_US="", zh_Hans=""), server_identifier=db_provider.server_identifier, + timeout=db_provider.timeout, + sse_read_timeout=db_provider.sse_read_timeout, + masked_headers=db_provider.masked_headers, + original_headers=db_provider.decrypted_headers, ) @staticmethod diff --git a/api/tests/test_containers_integration_tests/services/tools/test_mcp_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_mcp_tools_manage_service.py index 0fcaf86711..dd22dcbfd1 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_mcp_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_mcp_tools_manage_service.py @@ -706,7 +706,14 @@ class TestMCPToolManageService: # Verify mock interactions mock_mcp_client.assert_called_once_with( - "https://example.com/mcp", mcp_provider.id, tenant.id, authed=False, for_list=True + "https://example.com/mcp", + mcp_provider.id, + tenant.id, + authed=False, + for_list=True, + headers={}, + timeout=30.0, + sse_read_timeout=300.0, ) def test_list_mcp_tool_from_remote_server_auth_error( @@ -1181,6 +1188,11 @@ class TestMCPToolManageService: db_session_with_containers, mock_external_service_dependencies ) + # Create MCP provider first + mcp_provider = self._create_test_mcp_provider( + db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id + ) + # Mock MCPClient and its context manager mock_tools = [ type("MockTool", (), {"model_dump": lambda self: {"name": "test_tool_1", "description": "Test tool 1"}})(), @@ -1194,7 +1206,7 @@ class TestMCPToolManageService: # Act: Execute the method under test result = MCPToolManageService._re_connect_mcp_provider( - "https://example.com/mcp", "test_provider_id", tenant.id + "https://example.com/mcp", mcp_provider.id, tenant.id ) # Assert: Verify the expected outcomes @@ -1213,7 +1225,14 @@ class TestMCPToolManageService: # Verify mock interactions mock_mcp_client.assert_called_once_with( - "https://example.com/mcp", "test_provider_id", tenant.id, authed=False, for_list=True + "https://example.com/mcp", + mcp_provider.id, + tenant.id, + authed=False, + for_list=True, + headers={}, + timeout=30.0, + sse_read_timeout=300.0, ) def test_re_connect_mcp_provider_auth_error(self, db_session_with_containers, mock_external_service_dependencies): @@ -1231,6 +1250,11 @@ class TestMCPToolManageService: db_session_with_containers, mock_external_service_dependencies ) + # Create MCP provider first + mcp_provider = self._create_test_mcp_provider( + db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id + ) + # Mock MCPClient to raise authentication error with patch("services.tools.mcp_tools_manage_service.MCPClient") as mock_mcp_client: from core.mcp.error import MCPAuthError @@ -1240,7 +1264,7 @@ class TestMCPToolManageService: # Act: Execute the method under test result = MCPToolManageService._re_connect_mcp_provider( - "https://example.com/mcp", "test_provider_id", tenant.id + "https://example.com/mcp", mcp_provider.id, tenant.id ) # Assert: Verify the expected outcomes @@ -1265,6 +1289,11 @@ class TestMCPToolManageService: db_session_with_containers, mock_external_service_dependencies ) + # Create MCP provider first + mcp_provider = self._create_test_mcp_provider( + db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id + ) + # Mock MCPClient to raise connection error with patch("services.tools.mcp_tools_manage_service.MCPClient") as mock_mcp_client: from core.mcp.error import MCPError @@ -1274,4 +1303,4 @@ class TestMCPToolManageService: # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Failed to re-connect MCP server: Connection failed"): - MCPToolManageService._re_connect_mcp_provider("https://example.com/mcp", "test_provider_id", tenant.id) + MCPToolManageService._re_connect_mcp_provider("https://example.com/mcp", mcp_provider.id, tenant.id) diff --git a/web/app/components/tools/mcp/headers-input.tsx b/web/app/components/tools/mcp/headers-input.tsx new file mode 100644 index 0000000000..81d62993c9 --- /dev/null +++ b/web/app/components/tools/mcp/headers-input.tsx @@ -0,0 +1,143 @@ +'use client' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { RiAddLine, RiDeleteBinLine } from '@remixicon/react' +import Input from '@/app/components/base/input' +import Button from '@/app/components/base/button' +import ActionButton from '@/app/components/base/action-button' +import cn from '@/utils/classnames' + +export type HeaderItem = { + key: string + value: string +} + +type Props = { + headers: Record + onChange: (headers: Record) => void + readonly?: boolean + isMasked?: boolean +} + +const HeadersInput = ({ + headers, + onChange, + readonly = false, + isMasked = false, +}: Props) => { + const { t } = useTranslation() + + const headerItems = Object.entries(headers).map(([key, value]) => ({ key, value })) + + const handleItemChange = useCallback((index: number, field: 'key' | 'value', value: string) => { + const newItems = [...headerItems] + newItems[index] = { ...newItems[index], [field]: value } + + const newHeaders = newItems.reduce((acc, item) => { + if (item.key.trim()) + acc[item.key.trim()] = item.value + return acc + }, {} as Record) + + onChange(newHeaders) + }, [headerItems, onChange]) + + const handleRemoveItem = useCallback((index: number) => { + const newItems = headerItems.filter((_, i) => i !== index) + const newHeaders = newItems.reduce((acc, item) => { + if (item.key.trim()) + acc[item.key.trim()] = item.value + + return acc + }, {} as Record) + onChange(newHeaders) + }, [headerItems, onChange]) + + const handleAddItem = useCallback(() => { + const newHeaders = { ...headers, '': '' } + onChange(newHeaders) + }, [headers, onChange]) + + if (headerItems.length === 0) { + return ( +
+
+ {t('tools.mcp.modal.noHeaders')} +
+ {!readonly && ( + + )} +
+ ) + } + + return ( +
+ {isMasked && ( +
+ {t('tools.mcp.modal.maskedHeadersTip')} +
+ )} +
+
+
{t('tools.mcp.modal.headerKey')}
+
{t('tools.mcp.modal.headerValue')}
+
+ {headerItems.map((item, index) => ( +
+
+ handleItemChange(index, 'key', e.target.value)} + placeholder={t('tools.mcp.modal.headerKeyPlaceholder')} + className='rounded-none border-0' + readOnly={readonly} + /> +
+
+ handleItemChange(index, 'value', e.target.value)} + placeholder={t('tools.mcp.modal.headerValuePlaceholder')} + className='flex-1 rounded-none border-0' + readOnly={readonly} + /> + {!readonly && headerItems.length > 1 && ( + handleRemoveItem(index)} + className='mr-2' + > + + + )} +
+
+ ))} +
+ {!readonly && ( + + )} +
+ ) +} + +export default React.memo(HeadersInput) diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 2df8349a91..bf395cf1cb 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -9,6 +9,7 @@ import AppIcon from '@/app/components/base/app-icon' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import HeadersInput from './headers-input' import type { AppIconType } from '@/types/app' import type { ToolWithProvider } from '@/app/components/workflow/types' import { noop } from 'lodash-es' @@ -29,6 +30,7 @@ export type DuplicateAppModalProps = { server_identifier: string timeout: number sse_read_timeout: number + headers?: Record }) => void onHide: () => void } @@ -66,12 +68,38 @@ const MCPModal = ({ const [appIcon, setAppIcon] = useState(getIcon(data)) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') - const [timeout, setMcpTimeout] = React.useState(30) - const [sseReadTimeout, setSseReadTimeout] = React.useState(300) + const [timeout, setMcpTimeout] = React.useState(data?.timeout || 30) + const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.sse_read_timeout || 300) + const [headers, setHeaders] = React.useState>( + data?.masked_headers || {}, + ) const [isFetchingIcon, setIsFetchingIcon] = useState(false) const appIconRef = useRef(null) const isHovering = useHover(appIconRef) + // Update states when data changes (for edit mode) + React.useEffect(() => { + if (data) { + setUrl(data.server_url || '') + setName(data.name || '') + setServerIdentifier(data.server_identifier || '') + setMcpTimeout(data.timeout || 30) + setSseReadTimeout(data.sse_read_timeout || 300) + setHeaders(data.masked_headers || {}) + setAppIcon(getIcon(data)) + } + else { + // Reset for create mode + setUrl('') + setName('') + setServerIdentifier('') + setMcpTimeout(30) + setSseReadTimeout(300) + setHeaders({}) + setAppIcon(DEFAULT_ICON as AppIconSelection) + } + }, [data]) + const isValidUrl = (string: string) => { try { const urlPattern = /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})|localhost)(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?/i @@ -129,6 +157,7 @@ const MCPModal = ({ server_identifier: serverIdentifier.trim(), timeout: timeout || 30, sse_read_timeout: sseReadTimeout || 300, + headers: Object.keys(headers).length > 0 ? headers : undefined, }) if(isCreate) onHide() @@ -231,6 +260,18 @@ const MCPModal = ({ placeholder={t('tools.mcp.modal.timeoutPlaceholder')} /> +
+
+ {t('tools.mcp.modal.headers')} +
+
{t('tools.mcp.modal.headersTip')}
+ 0} + /> +
diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 01f436dedc..5a5c2e0400 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -59,6 +59,8 @@ export type Collection = { server_identifier?: string timeout?: number sse_read_timeout?: number + headers?: Record + masked_headers?: Record } export type ToolParameter = { @@ -184,4 +186,5 @@ export type MCPServerDetail = { description: string status: string parameters?: Record + headers?: Record } diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index dfbfb82d8b..97c557e62d 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -187,12 +187,22 @@ const translation = { serverIdentifier: 'Server Identifier', serverIdentifierTip: 'Unique identifier for the MCP server within the workspace. Lowercase letters, numbers, underscores, and hyphens only. Up to 24 characters.', serverIdentifierPlaceholder: 'Unique identifier, e.g., my-mcp-server', - serverIdentifierWarning: 'The server won’t be recognized by existing apps after an ID change', + serverIdentifierWarning: 'The server won\'t be recognized by existing apps after an ID change', + headers: 'Headers', + headersTip: 'Additional HTTP headers to send with MCP server requests', + headerKey: 'Header Name', + headerValue: 'Header Value', + headerKeyPlaceholder: 'e.g., Authorization', + headerValuePlaceholder: 'e.g., Bearer token123', + addHeader: 'Add Header', + noHeaders: 'No custom headers configured', + maskedHeadersTip: 'Header values are masked for security. Changes will update the actual values.', cancel: 'Cancel', save: 'Save', confirm: 'Add & Authorize', timeout: 'Timeout', sseReadTimeout: 'SSE Read Timeout', + timeoutPlaceholder: '30', }, delete: 'Remove MCP Server', deleteConfirmTitle: 'Would you like to remove {{mcp}}?', diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index f7c0055260..95ff8d649a 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -37,8 +37,8 @@ const translation = { tip: 'スタジオでワークフローをツールに公開する', }, mcp: { - title: '利用可能なMCPツールはありません', - tip: 'MCPサーバーを追加する', + title: '利用可能な MCP ツールはありません', + tip: 'MCP サーバーを追加する', }, agent: { title: 'Agent strategy は利用できません', @@ -85,13 +85,13 @@ const translation = { apiKeyPlaceholder: 'API キーの HTTP ヘッダー名', apiValuePlaceholder: 'API キーを入力してください', api_key_query: 'クエリパラメータ', - queryParamPlaceholder: 'APIキーのクエリパラメータ名', + queryParamPlaceholder: 'API キーのクエリパラメータ名', api_key_header: 'ヘッダー', }, key: 'キー', value: '値', queryParam: 'クエリパラメータ', - queryParamTooltip: 'APIキーのクエリパラメータとして渡す名前、例えば「https://example.com/test?key=API_KEY」の「key」。', + queryParamTooltip: 'API キーのクエリパラメータとして渡す名前、例えば「https://example.com/test?key=API_KEY」の「key」。', }, authHeaderPrefix: { title: '認証タイプ', @@ -169,32 +169,32 @@ const translation = { noTools: 'ツールが見つかりませんでした', mcp: { create: { - cardTitle: 'MCPサーバー(HTTP)を追加', - cardLink: 'MCPサーバー統合について詳しく知る', + cardTitle: 'MCP サーバー(HTTP)を追加', + cardLink: 'MCP サーバー統合について詳しく知る', }, noConfigured: '未設定', updateTime: '更新日時', toolsCount: '{{count}} 個のツール', noTools: '利用可能なツールはありません', modal: { - title: 'MCPサーバー(HTTP)を追加', - editTitle: 'MCPサーバー(HTTP)を編集', + title: 'MCP サーバー(HTTP)を追加', + editTitle: 'MCP サーバー(HTTP)を編集', name: '名前とアイコン', - namePlaceholder: 'MCPサーバーの名前を入力', + namePlaceholder: 'MCP サーバーの名前を入力', serverUrl: 'サーバーURL', - serverUrlPlaceholder: 'サーバーエンドポイントのURLを入力', + serverUrlPlaceholder: 'サーバーエンドポイントの URL を入力', serverUrlWarning: 'サーバーアドレスを更新すると、このサーバーに依存するアプリケーションに影響を与える可能性があります。', serverIdentifier: 'サーバー識別子', - serverIdentifierTip: 'ワークスペース内でのMCPサーバーのユニーク識別子です。使用可能な文字は小文字、数字、アンダースコア、ハイフンで、最大24文字です。', + serverIdentifierTip: 'ワークスペース内での MCP サーバーのユニーク識別子です。使用可能な文字は小文字、数字、アンダースコア、ハイフンで、最大 24 文字です。', serverIdentifierPlaceholder: 'ユニーク識別子(例:my-mcp-server)', - serverIdentifierWarning: 'IDを変更すると、既存のアプリケーションではサーバーが認識できなくなります。', + serverIdentifierWarning: 'ID を変更すると、既存のアプリケーションではサーバーが認識できなくなります。', cancel: 'キャンセル', save: '保存', confirm: '追加して承認', timeout: 'タイムアウト', sseReadTimeout: 'SSE 読み取りタイムアウト', }, - delete: 'MCPサーバーを削除', + delete: 'MCP サーバーを削除', deleteConfirmTitle: '{{mcp}} を削除しますか?', operation: { edit: '編集', @@ -213,23 +213,23 @@ const translation = { toolUpdateConfirmTitle: 'ツールリストの更新', toolUpdateConfirmContent: 'ツールリストを更新すると、既存のアプリケーションに重大な影響を与える可能性があります。続行しますか?', toolsNum: '{{count}} 個のツールが含まれています', - onlyTool: '1つのツールが含まれています', + onlyTool: '1 つのツールが含まれています', identifier: 'サーバー識別子(クリックしてコピー)', server: { - title: 'MCPサーバー', + title: 'MCP サーバー', url: 'サーバーURL', - reGen: 'サーバーURLを再生成しますか?', + reGen: 'サーバーURL を再生成しますか?', addDescription: '説明を追加', edit: '説明を編集', modal: { - addTitle: 'MCPサーバーを有効化するための説明を追加', + addTitle: 'MCP サーバーを有効化するための説明を追加', editTitle: '説明を編集', description: '説明', - descriptionPlaceholder: 'このツールの機能とLLM(大規模言語モデル)での使用方法を説明してください。', + descriptionPlaceholder: 'このツールの機能と LLM(大規模言語モデル)での使用方法を説明してください。', parameters: 'パラメータ', - parametersTip: '各パラメータの説明を追加して、LLMがその目的と制約を理解できるようにします。', + parametersTip: '各パラメータの説明を追加して、LLM がその目的と制約を理解できるようにします。', parametersPlaceholder: 'パラメータの目的と制約', - confirm: 'MCPサーバーを有効にする', + confirm: 'MCP サーバーを有効にする', }, publishTip: 'アプリが公開されていません。まずアプリを公開してください。', }, diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index 82be1c9bb0..9ade1caaad 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -81,7 +81,7 @@ const translation = { type: '鉴权类型', keyTooltip: 'HTTP 头部名称,如果你不知道是什么,可以将其保留为 Authorization 或设置为自定义值', queryParam: '查询参数', - queryParamTooltip: '用于传递 API 密钥查询参数的名称, 如 "https://example.com/test?key=API_KEY" 中的 "key"参数', + queryParamTooltip: '用于传递 API 密钥查询参数的名称,如 "https://example.com/test?key=API_KEY" 中的 "key"参数', types: { none: '无', api_key_header: '请求头', @@ -188,11 +188,21 @@ const translation = { serverIdentifierTip: '工作空间内服务器的唯一标识。支持小写字母、数字、下划线和连字符,最多 24 个字符。', serverIdentifierPlaceholder: '服务器唯一标识,例如 my-mcp-server', serverIdentifierWarning: '更改服务器标识符后,现有应用将无法识别此服务器', + headers: '请求头', + headersTip: '发送到 MCP 服务器的额外 HTTP 请求头', + headerKey: '请求头名称', + headerValue: '请求头值', + headerKeyPlaceholder: '例如:Authorization', + headerValuePlaceholder: '例如:Bearer token123', + addHeader: '添加请求头', + noHeaders: '未配置自定义请求头', + maskedHeadersTip: '为了安全,请求头值已被掩码处理。修改将更新实际值。', cancel: '取消', save: '保存', confirm: '添加并授权', timeout: '超时时间', sseReadTimeout: 'SSE 读取超时时间', + timeoutPlaceholder: '30', }, delete: '删除 MCP 服务', deleteConfirmTitle: '你想要删除 {{mcp}} 吗?', diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index 4db6039ed4..4bd265bf51 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -87,6 +87,7 @@ export const useCreateMCP = () => { icon_background?: string | null timeout?: number sse_read_timeout?: number + headers?: Record }) => { return post('workspaces/current/tool-provider/mcp', { body: { @@ -113,6 +114,7 @@ export const useUpdateMCP = ({ provider_id: string timeout?: number sse_read_timeout?: number + headers?: Record }) => { return put('workspaces/current/tool-provider/mcp', { body: { From cdfdf324e81b536bcce4b63822a5478b41ea8bf8 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Mon, 8 Sep 2025 15:08:56 +0800 Subject: [PATCH 02/10] Minor fix: correct PrecessRule typo (#25346) --- web/models/datasets.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/web/models/datasets.ts b/web/models/datasets.ts index bc00bf3f78..4546f2869c 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -391,11 +391,6 @@ export type createDocumentResponse = { documents: InitialDocumentDetail[] } -export type PrecessRule = { - mode: ProcessMode - rules: Rules -} - export type FullDocumentDetail = SimpleDocumentDetail & { batch: string created_api_request_id: string @@ -418,7 +413,7 @@ export type FullDocumentDetail = SimpleDocumentDetail & { doc_type?: DocType | null | 'others' doc_metadata?: DocMetadata | null segment_count: number - dataset_process_rule: PrecessRule + dataset_process_rule: ProcessRule document_process_rule: ProcessRule [key: string]: any } From 57f1822213cbbce2b7052f1397142c6622cfcf05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:37:20 +0800 Subject: [PATCH 03/10] chore: translate i18n files and update type definitions (#25349) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- web/i18n/de-DE/tools.ts | 10 ++++++++++ web/i18n/es-ES/tools.ts | 10 ++++++++++ web/i18n/fa-IR/tools.ts | 10 ++++++++++ web/i18n/fr-FR/tools.ts | 10 ++++++++++ web/i18n/hi-IN/tools.ts | 10 ++++++++++ web/i18n/id-ID/tools.ts | 10 ++++++++++ web/i18n/it-IT/tools.ts | 10 ++++++++++ web/i18n/ja-JP/tools.ts | 10 ++++++++++ web/i18n/ko-KR/tools.ts | 10 ++++++++++ web/i18n/pl-PL/tools.ts | 10 ++++++++++ web/i18n/pt-BR/tools.ts | 10 ++++++++++ web/i18n/ro-RO/tools.ts | 10 ++++++++++ web/i18n/ru-RU/tools.ts | 10 ++++++++++ web/i18n/sl-SI/tools.ts | 10 ++++++++++ web/i18n/th-TH/tools.ts | 10 ++++++++++ web/i18n/tr-TR/tools.ts | 10 ++++++++++ web/i18n/uk-UA/tools.ts | 10 ++++++++++ web/i18n/vi-VN/tools.ts | 10 ++++++++++ web/i18n/zh-Hant/tools.ts | 10 ++++++++++ 19 files changed, 190 insertions(+) diff --git a/web/i18n/de-DE/tools.ts b/web/i18n/de-DE/tools.ts index 377eb2d1f7..bf26ab9ee4 100644 --- a/web/i18n/de-DE/tools.ts +++ b/web/i18n/de-DE/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Hinzufügen & Autorisieren', sseReadTimeout: 'SSE-Lesezeitüberschreitung', timeout: 'Zeitüberschreitung', + headers: 'Kopfzeilen', + timeoutPlaceholder: 'dreißig', + headerKeyPlaceholder: 'z.B., Autorisierung', + addHeader: 'Kopfzeile hinzufügen', + headerValuePlaceholder: 'z.B., Träger Token123', + headerValue: 'Header-Wert', + headerKey: 'Kopfzeilenname', + noHeaders: 'Keine benutzerdefinierten Header konfiguriert', + maskedHeadersTip: 'Headerwerte sind zum Schutz maskiert. Änderungen werden die tatsächlichen Werte aktualisieren.', + headersTip: 'Zusätzliche HTTP-Header, die mit MCP-Serveranfragen gesendet werden sollen', }, delete: 'MCP-Server entfernen', deleteConfirmTitle: 'Möchten Sie {{mcp}} entfernen?', diff --git a/web/i18n/es-ES/tools.ts b/web/i18n/es-ES/tools.ts index 045cc57a3c..852fc94187 100644 --- a/web/i18n/es-ES/tools.ts +++ b/web/i18n/es-ES/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Añadir y Autorizar', sseReadTimeout: 'Tiempo de espera de lectura SSE', timeout: 'Tiempo de espera', + timeoutPlaceholder: 'treinta', + headers: 'Encabezados', + addHeader: 'Agregar encabezado', + headerValuePlaceholder: 'por ejemplo, token de portador123', + headersTip: 'Encabezados HTTP adicionales para enviar con las solicitudes del servidor MCP', + maskedHeadersTip: 'Los valores del encabezado están enmascarados por seguridad. Los cambios actualizarán los valores reales.', + headerKeyPlaceholder: 'por ejemplo, Autorización', + headerValue: 'Valor del encabezado', + noHeaders: 'No se han configurado encabezados personalizados', + headerKey: 'Nombre del encabezado', }, delete: 'Eliminar servidor MCP', deleteConfirmTitle: '¿Eliminar {{mcp}}?', diff --git a/web/i18n/fa-IR/tools.ts b/web/i18n/fa-IR/tools.ts index 82f2767015..c321ff5131 100644 --- a/web/i18n/fa-IR/tools.ts +++ b/web/i18n/fa-IR/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'افزودن و مجوزدهی', timeout: 'مهلت', sseReadTimeout: 'زمان.out خواندن SSE', + headers: 'عناوین', + timeoutPlaceholder: 'سی', + headerKey: 'نام هدر', + headerValue: 'مقدار هدر', + addHeader: 'هدر اضافه کنید', + headerKeyPlaceholder: 'به عنوان مثال، مجوز', + headerValuePlaceholder: 'مثلاً، توکن حامل ۱۲۳', + noHeaders: 'هیچ هدر سفارشی پیکربندی نشده است', + headersTip: 'سرفصل‌های اضافی HTTP برای ارسال با درخواست‌های سرور MCP', + maskedHeadersTip: 'مقدارهای هدر به خاطر امنیت مخفی شده‌اند. تغییرات مقادیر واقعی را به‌روزرسانی خواهد کرد.', }, delete: 'حذف سرور MCP', deleteConfirmTitle: 'آیا مایل به حذف {mcp} هستید؟', diff --git a/web/i18n/fr-FR/tools.ts b/web/i18n/fr-FR/tools.ts index 9e1d5e50ba..bab19e0f04 100644 --- a/web/i18n/fr-FR/tools.ts +++ b/web/i18n/fr-FR/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Ajouter & Authoriser', sseReadTimeout: 'Délai d\'attente de lecture SSE', timeout: 'Délai d\'attente', + timeoutPlaceholder: 'trente', + headerValue: 'Valeur d\'en-tête', + headerKey: 'Nom de l\'en-tête', + noHeaders: 'Aucun en-tête personnalisé configuré', + headers: 'En-têtes', + headerKeyPlaceholder: 'par exemple, Autorisation', + headerValuePlaceholder: 'par exemple, Jeton d\'accès123', + headersTip: 'En-têtes HTTP supplémentaires à envoyer avec les requêtes au serveur MCP', + addHeader: 'Ajouter un en-tête', + maskedHeadersTip: 'Les valeurs d\'en-tête sont masquées pour des raisons de sécurité. Les modifications mettront à jour les valeurs réelles.', }, delete: 'Supprimer le Serveur MCP', deleteConfirmTitle: 'Souhaitez-vous supprimer {mcp}?', diff --git a/web/i18n/hi-IN/tools.ts b/web/i18n/hi-IN/tools.ts index a3479df6d6..a4a2c5f81a 100644 --- a/web/i18n/hi-IN/tools.ts +++ b/web/i18n/hi-IN/tools.ts @@ -198,6 +198,16 @@ const translation = { confirm: 'जोड़ें और अधिकृत करें', timeout: 'टाइमआउट', sseReadTimeout: 'एसएसई पढ़ने का टाइमआउट', + headerKey: 'हेडर नाम', + headers: 'हेडर', + headerValue: 'हेडर मान', + timeoutPlaceholder: 'तीस', + headerValuePlaceholder: 'उदाहरण के लिए, बियरर टोकन123', + addHeader: 'हेडर जोड़ें', + headerKeyPlaceholder: 'उदाहरण के लिए, प्राधिकरण', + noHeaders: 'कोई कस्टम हेडर कॉन्फ़िगर नहीं किए गए हैं', + maskedHeadersTip: 'सुरक्षा के लिए हेडर मानों को छिपाया गया है। परिवर्तन वास्तविक मानों को अपडेट करेगा।', + headersTip: 'MCP सर्वर अनुरोधों के साथ भेजने के लिए अतिरिक्त HTTP हेडर्स', }, delete: 'MCP सर्वर हटाएँ', deleteConfirmTitle: '{mcp} हटाना चाहते हैं?', diff --git a/web/i18n/id-ID/tools.ts b/web/i18n/id-ID/tools.ts index 3874f55a00..5b2f5f17c2 100644 --- a/web/i18n/id-ID/tools.ts +++ b/web/i18n/id-ID/tools.ts @@ -175,6 +175,16 @@ const translation = { cancel: 'Membatalkan', serverIdentifierPlaceholder: 'Pengidentifikasi unik, misalnya, my-mcp-server', serverUrl: 'Server URL', + headers: 'Header', + timeoutPlaceholder: 'tiga puluh', + addHeader: 'Tambahkan Judul', + headerKey: 'Nama Header', + headerValue: 'Nilai Header', + headersTip: 'Header HTTP tambahan untuk dikirim bersama permintaan server MCP', + headerKeyPlaceholder: 'misalnya, Otorisasi', + headerValuePlaceholder: 'misalnya, Token Pengganti 123', + noHeaders: 'Tidak ada header kustom yang dikonfigurasi', + maskedHeadersTip: 'Nilai header disembunyikan untuk keamanan. Perubahan akan memperbarui nilai yang sebenarnya.', }, operation: { edit: 'Mengedit', diff --git a/web/i18n/it-IT/tools.ts b/web/i18n/it-IT/tools.ts index db305118a4..43476f97d8 100644 --- a/web/i18n/it-IT/tools.ts +++ b/web/i18n/it-IT/tools.ts @@ -203,6 +203,16 @@ const translation = { confirm: 'Aggiungi & Autorizza', timeout: 'Tempo scaduto', sseReadTimeout: 'Timeout di lettura SSE', + headerKey: 'Nome intestazione', + timeoutPlaceholder: 'trenta', + headers: 'Intestazioni', + addHeader: 'Aggiungi intestazione', + noHeaders: 'Nessuna intestazione personalizzata configurata', + headerKeyPlaceholder: 'ad es., Autorizzazione', + headerValue: 'Valore dell\'intestazione', + headerValuePlaceholder: 'ad esempio, Token di accesso123', + headersTip: 'Intestazioni HTTP aggiuntive da inviare con le richieste al server MCP', + maskedHeadersTip: 'I valori dell\'intestazione sono mascherati per motivi di sicurezza. Le modifiche aggiorneranno i valori effettivi.', }, delete: 'Rimuovi Server MCP', deleteConfirmTitle: 'Vuoi rimuovere {mcp}?', diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index 95ff8d649a..93e136a30e 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: '追加して承認', timeout: 'タイムアウト', sseReadTimeout: 'SSE 読み取りタイムアウト', + headerValuePlaceholder: '例:ベアラートークン123', + headerKeyPlaceholder: '例えば、承認', + headers: 'ヘッダー', + timeoutPlaceholder: '三十', + headerKey: 'ヘッダー名', + addHeader: 'ヘッダーを追加', + headerValue: 'ヘッダーの値', + noHeaders: 'カスタムヘッダーは設定されていません', + headersTip: 'MCPサーバーへのリクエストに送信する追加のHTTPヘッダー', + maskedHeadersTip: 'ヘッダー値はセキュリティのためマスクされています。変更は実際の値を更新します。', }, delete: 'MCP サーバーを削除', deleteConfirmTitle: '{{mcp}} を削除しますか?', diff --git a/web/i18n/ko-KR/tools.ts b/web/i18n/ko-KR/tools.ts index 2598b4490a..823181f9bc 100644 --- a/web/i18n/ko-KR/tools.ts +++ b/web/i18n/ko-KR/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: '추가 및 승인', timeout: '타임아웃', sseReadTimeout: 'SSE 읽기 타임아웃', + headers: '헤더', + headerKeyPlaceholder: '예: 승인', + headerKey: '헤더 이름', + headerValuePlaceholder: '예: 베어러 토큰123', + timeoutPlaceholder: '서른', + headerValue: '헤더 값', + addHeader: '헤더 추가', + noHeaders: '사용자 정의 헤더가 구성되어 있지 않습니다.', + headersTip: 'MCP 서버 요청과 함께 보낼 추가 HTTP 헤더', + maskedHeadersTip: '헤더 값은 보안상 마스킹 처리되어 있습니다. 변경 사항은 실제 값에 업데이트됩니다.', }, delete: 'MCP 서버 제거', deleteConfirmTitle: '{mcp}를 제거하시겠습니까?', diff --git a/web/i18n/pl-PL/tools.ts b/web/i18n/pl-PL/tools.ts index dc05f6b239..5272762a85 100644 --- a/web/i18n/pl-PL/tools.ts +++ b/web/i18n/pl-PL/tools.ts @@ -197,6 +197,16 @@ const translation = { confirm: 'Dodaj i autoryzuj', timeout: 'Limit czasu', sseReadTimeout: 'Przekroczenie czasu oczekiwania na odczyt SSE', + addHeader: 'Dodaj nagłówek', + headers: 'Nagłówki', + headerKeyPlaceholder: 'np. Autoryzacja', + timeoutPlaceholder: 'trzydzieści', + headerValuePlaceholder: 'np. Token dostępu 123', + headerKey: 'Nazwa nagłówka', + headersTip: 'Dodatkowe nagłówki HTTP do wysłania z żądaniami serwera MCP', + headerValue: 'Wartość nagłówka', + noHeaders: 'Brak skonfigurowanych nagłówków niestandardowych', + maskedHeadersTip: 'Wartości nagłówków są ukryte dla bezpieczeństwa. Zmiany zaktualizują rzeczywiste wartości.', }, delete: 'Usuń serwer MCP', deleteConfirmTitle: 'Usunąć {mcp}?', diff --git a/web/i18n/pt-BR/tools.ts b/web/i18n/pt-BR/tools.ts index 4b12902b0c..3b19bc57ee 100644 --- a/web/i18n/pt-BR/tools.ts +++ b/web/i18n/pt-BR/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Adicionar e Autorizar', sseReadTimeout: 'Tempo limite de leitura SSE', timeout: 'Tempo esgotado', + timeoutPlaceholder: 'trinta', + headerValue: 'Valor do Cabeçalho', + headerKeyPlaceholder: 'por exemplo, Autorização', + addHeader: 'Adicionar Cabeçalho', + headersTip: 'Cabeçalhos HTTP adicionais a serem enviados com as solicitações do servidor MCP', + headers: 'Cabeçalhos', + maskedHeadersTip: 'Os valores do cabeçalho estão mascarados por segurança. As alterações atualizarão os valores reais.', + headerKey: 'Nome do Cabeçalho', + noHeaders: 'Nenhum cabeçalho personalizado configurado', + headerValuePlaceholder: 'ex: Token de portador 123', }, delete: 'Remover Servidor MCP', deleteConfirmTitle: 'Você gostaria de remover {{mcp}}?', diff --git a/web/i18n/ro-RO/tools.ts b/web/i18n/ro-RO/tools.ts index 71d9fa50f7..4af40af668 100644 --- a/web/i18n/ro-RO/tools.ts +++ b/web/i18n/ro-RO/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Adăugare și Autorizare', timeout: 'Timp de așteptare', sseReadTimeout: 'Timp de așteptare pentru citirea SSE', + headerKeyPlaceholder: 'de exemplu, Autorizație', + headers: 'Antete', + addHeader: 'Adăugați antet', + headerValuePlaceholder: 'de exemplu, Bearer token123', + timeoutPlaceholder: 'treizeci', + headerKey: 'Numele antetului', + headerValue: 'Valoare Antet', + maskedHeadersTip: 'Valorile de antet sunt mascate pentru securitate. Modificările vor actualiza valorile reale.', + headersTip: 'Header-uri HTTP suplimentare de trimis cu cererile către serverul MCP', + noHeaders: 'Nu sunt configurate antete personalizate.', }, delete: 'Eliminare Server MCP', deleteConfirmTitle: 'Ștergeți {mcp}?', diff --git a/web/i18n/ru-RU/tools.ts b/web/i18n/ru-RU/tools.ts index b02663d86b..aacc774adf 100644 --- a/web/i18n/ru-RU/tools.ts +++ b/web/i18n/ru-RU/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Добавить и авторизовать', timeout: 'Тайм-аут', sseReadTimeout: 'Таймаут чтения SSE', + headerValuePlaceholder: 'например, Токен носителя 123', + headers: 'Заголовки', + headerKey: 'Название заголовка', + timeoutPlaceholder: 'тридцать', + addHeader: 'Добавить заголовок', + headerValue: 'Значение заголовка', + headerKeyPlaceholder: 'например, Авторизация', + noHeaders: 'Нет настроенных пользовательских заголовков', + maskedHeadersTip: 'Значения заголовков скрыты для безопасности. Изменения обновят фактические значения.', + headersTip: 'Дополнительные HTTP заголовки для отправки с запросами к серверу MCP', }, delete: 'Удалить MCP сервер', deleteConfirmTitle: 'Вы действительно хотите удалить {mcp}?', diff --git a/web/i18n/sl-SI/tools.ts b/web/i18n/sl-SI/tools.ts index 6a9b4b92bd..9465c32e57 100644 --- a/web/i18n/sl-SI/tools.ts +++ b/web/i18n/sl-SI/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Dodaj in avtoriziraj', timeout: 'Časovna omejitev', sseReadTimeout: 'SSE časovna omejitev branja', + timeoutPlaceholder: 'trideset', + headers: 'Naslovi', + headerKeyPlaceholder: 'npr., Pooblastitev', + headerValue: 'Vrednost glave', + headerKey: 'Ime glave', + addHeader: 'Dodaj naslov', + headersTip: 'Dodatni HTTP glavi za poslati z zahtevami MCP strežnika', + headerValuePlaceholder: 'npr., nosilec žeton123', + noHeaders: 'Nobenih prilagojenih glave ni konfiguriranih', + maskedHeadersTip: 'Vrednosti glave so zakrite zaradi varnosti. Spremembe bodo posodobile dejanske vrednosti.', }, delete: 'Odstrani strežnik MCP', deleteConfirmTitle: 'Odstraniti {mcp}?', diff --git a/web/i18n/th-TH/tools.ts b/web/i18n/th-TH/tools.ts index 54cf5ccd11..32fa56af11 100644 --- a/web/i18n/th-TH/tools.ts +++ b/web/i18n/th-TH/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'เพิ่มและอนุญาต', timeout: 'หมดเวลา', sseReadTimeout: 'หมดเวลาการอ่าน SSE', + timeoutPlaceholder: 'สามสิบ', + headerValue: 'ค่าหัวข้อ', + addHeader: 'เพิ่มหัวเรื่อง', + headerKey: 'ชื่อหัวเรื่อง', + headerKeyPlaceholder: 'เช่น การอนุญาต', + headerValuePlaceholder: 'ตัวอย่าง: รหัสตัวแทน token123', + headers: 'หัวเรื่อง', + noHeaders: 'ไม่มีการกำหนดหัวข้อที่กำหนดเอง', + headersTip: 'HTTP header เพิ่มเติมที่จะส่งไปกับคำขอ MCP server', + maskedHeadersTip: 'ค่าหัวถูกปกปิดเพื่อความปลอดภัย การเปลี่ยนแปลงจะปรับปรุงค่าที่แท้จริง', }, delete: 'ลบเซิร์ฟเวอร์ MCP', deleteConfirmTitle: 'คุณต้องการลบ {mcp} หรือไม่?', diff --git a/web/i18n/tr-TR/tools.ts b/web/i18n/tr-TR/tools.ts index 890af6e9f2..3f7d1c7d83 100644 --- a/web/i18n/tr-TR/tools.ts +++ b/web/i18n/tr-TR/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Ekle ve Yetkilendir', timeout: 'Zaman aşımı', sseReadTimeout: 'SSE Okuma Zaman Aşımı', + headers: 'Başlıklar', + headerKeyPlaceholder: 'örneğin, Yetkilendirme', + addHeader: 'Başlık Ekle', + headerValue: 'Başlık Değeri', + noHeaders: 'Özel başlıklar yapılandırılmamış', + headerKey: 'Başlık Adı', + timeoutPlaceholder: 'otuz', + headersTip: 'MCP sunucu istekleri ile gönderilecek ek HTTP başlıkları', + headerValuePlaceholder: 'örneğin, Taşıyıcı jeton123', + maskedHeadersTip: 'Başlık değerleri güvenlik amacıyla gizlenmiştir. Değişiklikler gerçek değerleri güncelleyecektir.', }, delete: 'MCP Sunucusunu Kaldır', deleteConfirmTitle: '{mcp} kaldırılsın mı?', diff --git a/web/i18n/uk-UA/tools.ts b/web/i18n/uk-UA/tools.ts index 0b7dd2d1e8..3f7350d501 100644 --- a/web/i18n/uk-UA/tools.ts +++ b/web/i18n/uk-UA/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Додати та Авторизувати', timeout: 'Час вичерпано', sseReadTimeout: 'Тайм-аут читання SSE', + headers: 'Заголовки', + headerValuePlaceholder: 'наприклад, токен носія 123', + headerValue: 'Значення заголовка', + headerKey: 'Назва заголовка', + timeoutPlaceholder: 'тридцять', + addHeader: 'Додати заголовок', + noHeaders: 'Не налаштовано спеціальні заголовки', + headerKeyPlaceholder: 'наприклад, Авторизація', + maskedHeadersTip: 'Значення заголовків маскуються для безпеки. Зміни оновлять фактичні значення.', + headersTip: 'Додаткові HTTP заголовки для відправлення з запитами до сервера MCP', }, delete: 'Видалити сервер MCP', deleteConfirmTitle: 'Видалити {mcp}?', diff --git a/web/i18n/vi-VN/tools.ts b/web/i18n/vi-VN/tools.ts index afd6683c72..23a1cf0816 100644 --- a/web/i18n/vi-VN/tools.ts +++ b/web/i18n/vi-VN/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: 'Thêm & Ủy quyền', sseReadTimeout: 'Thời gian chờ Đọc SSE', timeout: 'Thời gian chờ', + headerKeyPlaceholder: 'ví dụ, Ủy quyền', + timeoutPlaceholder: 'ba mươi', + addHeader: 'Thêm tiêu đề', + headers: 'Tiêu đề', + headerValuePlaceholder: 'ví dụ: mã thông báo Bearer123', + headerKey: 'Tên tiêu đề', + noHeaders: 'Không có tiêu đề tùy chỉnh nào được cấu hình', + headerValue: 'Giá trị tiêu đề', + maskedHeadersTip: 'Các giá trị tiêu đề được mã hóa để đảm bảo an ninh. Các thay đổi sẽ cập nhật các giá trị thực tế.', + headersTip: 'Các tiêu đề HTTP bổ sung để gửi cùng với các yêu cầu máy chủ MCP', }, delete: 'Xóa Máy chủ MCP', deleteConfirmTitle: 'Xóa {mcp}?', diff --git a/web/i18n/zh-Hant/tools.ts b/web/i18n/zh-Hant/tools.ts index 821e90a084..b96de99e80 100644 --- a/web/i18n/zh-Hant/tools.ts +++ b/web/i18n/zh-Hant/tools.ts @@ -193,6 +193,16 @@ const translation = { confirm: '新增並授權', sseReadTimeout: 'SSE 讀取超時', timeout: '超時', + headerValue: '標題值', + headerKey: '標題名稱', + noHeaders: '沒有配置自定義標頭', + timeoutPlaceholder: '三十', + headerValuePlaceholder: '例如,承載者令牌123', + addHeader: '添加標題', + headerKeyPlaceholder: '例如,授權', + headersTip: '與 MCP 伺服器請求一同發送的附加 HTTP 標頭', + maskedHeadersTip: '標頭值已被遮罩以保障安全。更改將更新實際值。', + headers: '標題', }, delete: '刪除 MCP 伺服器', deleteConfirmTitle: '您確定要刪除 {{mcp}} 嗎?', From 74be2087b556f6aa05ee099b204f5e7ba8bd5e0b Mon Sep 17 00:00:00 2001 From: "Krito." Date: Mon, 8 Sep 2025 16:38:09 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20ensure=20Performance=20Tracing=20b?= =?UTF-8?q?utton=20visible=20when=20no=20tracing=20provid=E2=80=A6=20(#253?= =?UTF-8?q?51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/core/ops/ops_trace_manager.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 1bc87023d5..a2f1969bc8 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -323,14 +323,11 @@ class OpsTraceManager: :return: """ # auth check - if enabled: - try: + try: + if enabled or tracing_provider is not None: provider_config_map[tracing_provider] - except KeyError: - raise ValueError(f"Invalid tracing provider: {tracing_provider}") - else: - if tracing_provider is None: - raise ValueError(f"Invalid tracing provider: {tracing_provider}") + except KeyError: + raise ValueError(f"Invalid tracing provider: {tracing_provider}") app_config: Optional[App] = db.session.query(App).where(App.id == app_id).first() if not app_config: From 860ee20c71cace6ccf733af475493cc33181d633 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 8 Sep 2025 17:51:43 +0800 Subject: [PATCH 05/10] feat: email register refactor (#25344) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/.env.example | 1 + api/configs/feature/__init__.py | 11 ++ api/controllers/console/__init__.py | 11 +- .../console/auth/email_register.py | 154 ++++++++++++++++++ api/controllers/console/auth/error.py | 12 ++ .../console/auth/forgot_password.py | 39 +---- api/controllers/console/auth/login.py | 28 +--- api/controllers/console/wraps.py | 13 ++ api/libs/email_i18n.py | 52 ++++++ api/services/account_service.py | 111 ++++++++++++- api/tasks/mail_register_task.py | 86 ++++++++++ api/tasks/mail_reset_password_task.py | 45 +++++ .../register_email_template_en-US.html | 87 ++++++++++ .../register_email_template_zh-CN.html | 87 ++++++++++ ...ail_when_account_exist_template_en-US.html | 94 +++++++++++ ...ail_when_account_exist_template_zh-CN.html | 95 +++++++++++ ..._not_exist_no_register_template_en-US.html | 85 ++++++++++ ..._not_exist_no_register_template_zh-CN.html | 84 ++++++++++ ...when_account_not_exist_template_en-US.html | 89 ++++++++++ ...when_account_not_exist_template_zh-CN.html | 89 ++++++++++ .../register_email_template_en-US.html | 83 ++++++++++ .../register_email_template_zh-CN.html | 83 ++++++++++ ...ail_when_account_exist_template_en-US.html | 90 ++++++++++ ...ail_when_account_exist_template_zh-CN.html | 91 +++++++++++ ..._not_exist_no_register_template_en-US.html | 81 +++++++++ ..._not_exist_no_register_template_zh-CN.html | 81 +++++++++ ...when_account_not_exist_template_en-US.html | 85 ++++++++++ ...when_account_not_exist_template_zh-CN.html | 85 ++++++++++ api/tests/integration_tests/.env.example | 1 + .../services/test_account_service.py | 3 +- .../auth/test_authentication_security.py | 34 ++-- .../services/test_account_service.py | 3 +- docker/.env.example | 1 + docker/docker-compose.yaml | 1 + 34 files changed, 1916 insertions(+), 79 deletions(-) create mode 100644 api/controllers/console/auth/email_register.py create mode 100644 api/tasks/mail_register_task.py create mode 100644 api/templates/register_email_template_en-US.html create mode 100644 api/templates/register_email_template_zh-CN.html create mode 100644 api/templates/register_email_when_account_exist_template_en-US.html create mode 100644 api/templates/register_email_when_account_exist_template_zh-CN.html create mode 100644 api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html create mode 100644 api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html create mode 100644 api/templates/reset_password_mail_when_account_not_exist_template_en-US.html create mode 100644 api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html create mode 100644 api/templates/without-brand/register_email_template_en-US.html create mode 100644 api/templates/without-brand/register_email_template_zh-CN.html create mode 100644 api/templates/without-brand/register_email_when_account_exist_template_en-US.html create mode 100644 api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html create mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html create mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html create mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html create mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html diff --git a/api/.env.example b/api/.env.example index eb88c114e6..76f4c505f5 100644 --- a/api/.env.example +++ b/api/.env.example @@ -530,6 +530,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 7638cd1899..d6dc9710fb 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -31,6 +31,12 @@ class SecurityConfig(BaseSettings): description="Duration in minutes for which a password reset token remains valid", default=5, ) + + EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a email register token remains valid", + default=5, + ) + CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( description="Duration in minutes for which a change email token remains valid", default=5, @@ -639,6 +645,11 @@ class AuthConfig(BaseSettings): default=86400, ) + EMAIL_REGISTER_LOCKOUT_DURATION: PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying email register after exceeding the rate limit.", + default=86400, + ) + class ModerationConfig(BaseSettings): """ diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 5ad7645969..9634f3ca17 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -70,7 +70,16 @@ from .app import ( ) # Import auth controllers -from .auth import activate, data_source_bearer_auth, data_source_oauth, forgot_password, login, oauth, oauth_server +from .auth import ( + activate, + data_source_bearer_auth, + data_source_oauth, + email_register, + forgot_password, + login, + oauth, + oauth_server, +) # Import billing controllers from .billing import billing, compliance diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py new file mode 100644 index 0000000000..458e70c8de --- /dev/null +++ b/api/controllers/console/auth/email_register.py @@ -0,0 +1,154 @@ +from flask import request +from flask_restx import Resource, reqparse +from sqlalchemy import select +from sqlalchemy.orm import Session + +from constants.languages import languages +from controllers.console import api +from controllers.console.auth.error import ( + EmailAlreadyInUseError, + EmailCodeError, + EmailRegisterLimitError, + InvalidEmailError, + InvalidTokenError, + PasswordMismatchError, +) +from controllers.console.error import AccountInFreezeError, EmailSendIpLimitError +from controllers.console.wraps import email_password_login_enabled, email_register_enabled, setup_required +from extensions.ext_database import db +from libs.helper import email, extract_remote_ip +from libs.password import valid_password +from models.account import Account +from services.account_service import AccountService +from services.errors.account import AccountRegisterError +from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError + + +class EmailRegisterSendEmailApi(Resource): + @setup_required + @email_password_login_enabled + @email_register_enabled + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + parser.add_argument("language", type=str, required=False, location="json") + args = parser.parse_args() + + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + + with Session(db.engine) as session: + account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() + token = None + token = AccountService.send_email_register_email(email=args["email"], account=account, language=language) + return {"result": "success", "data": token} + + +class EmailRegisterCheckApi(Resource): + @setup_required + @email_password_login_enabled + @email_register_enabled + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=str, required=True, location="json") + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + user_email = args["email"] + + is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(args["email"]) + if is_email_register_error_rate_limit: + raise EmailRegisterLimitError() + + token_data = AccountService.get_email_register_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_email_register_error_rate_limit(args["email"]) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_email_register_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_email_register_token( + user_email, code=args["code"], additional_data={"phase": "register"} + ) + + AccountService.reset_email_register_error_rate_limit(args["email"]) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + + +class EmailRegisterResetApi(Resource): + @setup_required + @email_password_login_enabled + @email_register_enabled + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json") + parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json") + args = parser.parse_args() + + # Validate passwords match + if args["new_password"] != args["password_confirm"]: + raise PasswordMismatchError() + + # Validate token and get register data + register_data = AccountService.get_email_register_data(args["token"]) + if not register_data: + raise InvalidTokenError() + # Must use token in reset phase + if register_data.get("phase", "") != "register": + raise InvalidTokenError() + + # Revoke token to prevent reuse + AccountService.revoke_email_register_token(args["token"]) + + email = register_data.get("email", "") + + with Session(db.engine) as session: + account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none() + + if account: + raise EmailAlreadyInUseError() + else: + account = self._create_new_account(email, args["password_confirm"]) + token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) + AccountService.reset_login_error_rate_limit(email) + + return {"result": "success", "data": token_pair.model_dump()} + + def _create_new_account(self, email, password): + # Create new account if allowed + try: + account = AccountService.create_account_and_tenant( + email=email, + name=email, + password=password, + interface_language=languages[0], + ) + except WorkSpaceNotAllowedCreateError: + pass + except WorkspacesLimitExceededError: + pass + except AccountRegisterError: + raise AccountInFreezeError() + + return account + + +api.add_resource(EmailRegisterSendEmailApi, "/email-register/send-email") +api.add_resource(EmailRegisterCheckApi, "/email-register/validity") +api.add_resource(EmailRegisterResetApi, "/email-register") diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 7853bef917..9cda8c90b1 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -31,6 +31,12 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 +class EmailRegisterRateLimitExceededError(BaseHTTPException): + error_code = "email_register_rate_limit_exceeded" + description = "Too many email register emails have been sent. Please try again in 1 minute." + code = 429 + + class EmailChangeRateLimitExceededError(BaseHTTPException): error_code = "email_change_rate_limit_exceeded" description = "Too many email change emails have been sent. Please try again in 1 minute." @@ -85,6 +91,12 @@ class EmailPasswordResetLimitError(BaseHTTPException): code = 429 +class EmailRegisterLimitError(BaseHTTPException): + error_code = "email_register_limit" + description = "Too many failed email register attempts. Please try again in 24 hours." + code = 429 + + class EmailChangeLimitError(BaseHTTPException): error_code = "email_change_limit" description = "Too many failed email change attempts. Please try again in 24 hours." diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index ede0696854..d7558e0f67 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -6,7 +6,6 @@ from flask_restx import Resource, reqparse from sqlalchemy import select from sqlalchemy.orm import Session -from constants.languages import languages from controllers.console import api from controllers.console.auth.error import ( EmailCodeError, @@ -15,7 +14,7 @@ from controllers.console.auth.error import ( InvalidTokenError, PasswordMismatchError, ) -from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError +from controllers.console.error import AccountNotFound, EmailSendIpLimitError from controllers.console.wraps import email_password_login_enabled, setup_required from events.tenant_event import tenant_was_created from extensions.ext_database import db @@ -23,8 +22,6 @@ from libs.helper import email, extract_remote_ip from libs.password import hash_password, valid_password from models.account import Account from services.account_service import AccountService, TenantService -from services.errors.account import AccountRegisterError -from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService @@ -48,15 +45,13 @@ class ForgotPasswordSendEmailApi(Resource): with Session(db.engine) as session: account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() - token = None - if account is None: - if FeatureService.get_system_features().is_allow_register: - token = AccountService.send_reset_password_email(email=args["email"], language=language) - return {"result": "fail", "data": token, "code": "account_not_found"} - else: - raise AccountNotFound() - else: - token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language) + + token = AccountService.send_reset_password_email( + account=account, + email=args["email"], + language=language, + is_allow_register=FeatureService.get_system_features().is_allow_register, + ) return {"result": "success", "data": token} @@ -137,7 +132,7 @@ class ForgotPasswordResetApi(Resource): if account: self._update_existing_account(account, password_hashed, salt, session) else: - self._create_new_account(email, args["password_confirm"]) + raise AccountNotFound() return {"result": "success"} @@ -157,22 +152,6 @@ class ForgotPasswordResetApi(Resource): account.current_tenant = tenant tenant_was_created.send(tenant) - def _create_new_account(self, email, password): - # Create new account if allowed - try: - AccountService.create_account_and_tenant( - email=email, - name=email, - password=password, - interface_language=languages[0], - ) - except WorkSpaceNotAllowedCreateError: - pass - except WorkspacesLimitExceededError: - pass - except AccountRegisterError: - raise AccountInFreezeError() - api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password") api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity") diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index b11bc0c6ac..3b35ab3c23 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -26,7 +26,6 @@ from controllers.console.error import ( from controllers.console.wraps import email_password_login_enabled, setup_required from events.tenant_event import tenant_was_created from libs.helper import email, extract_remote_ip -from libs.password import valid_password from models.account import Account from services.account_service import AccountService, RegisterService, TenantService from services.billing_service import BillingService @@ -44,10 +43,9 @@ class LoginApi(Resource): """Authenticate user and login.""" parser = reqparse.RequestParser() parser.add_argument("email", type=email, required=True, location="json") - parser.add_argument("password", type=valid_password, required=True, location="json") + parser.add_argument("password", type=str, required=True, location="json") parser.add_argument("remember_me", type=bool, required=False, default=False, location="json") parser.add_argument("invite_token", type=str, required=False, default=None, location="json") - parser.add_argument("language", type=str, required=False, default="en-US", location="json") args = parser.parse_args() if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): @@ -61,11 +59,6 @@ class LoginApi(Resource): if invitation: invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation) - if args["language"] is not None and args["language"] == "zh-Hans": - language = "zh-Hans" - else: - language = "en-US" - try: if invitation: data = invitation.get("data", {}) @@ -80,12 +73,6 @@ class LoginApi(Resource): except services.errors.account.AccountPasswordError: AccountService.add_login_error_rate_limit(args["email"]) raise AuthenticationFailedError() - except services.errors.account.AccountNotFoundError: - if FeatureService.get_system_features().is_allow_register: - token = AccountService.send_reset_password_email(email=args["email"], language=language) - return {"result": "fail", "data": token, "code": "account_not_found"} - else: - raise AccountNotFound() # SELF_HOSTED only have one workspace tenants = TenantService.get_join_tenants(account) if len(tenants) == 0: @@ -133,13 +120,12 @@ class ResetPasswordSendEmailApi(Resource): except AccountRegisterError: raise AccountInFreezeError() - if account is None: - if FeatureService.get_system_features().is_allow_register: - token = AccountService.send_reset_password_email(email=args["email"], language=language) - else: - raise AccountNotFound() - else: - token = AccountService.send_reset_password_email(account=account, language=language) + token = AccountService.send_reset_password_email( + email=args["email"], + account=account, + language=language, + is_allow_register=FeatureService.get_system_features().is_allow_register, + ) return {"result": "success", "data": token} diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index e375fe285b..092071481e 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -242,6 +242,19 @@ def email_password_login_enabled(view: Callable[P, R]): return decorated +def email_register_enabled(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_system_features() + if features.is_allow_register: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated + + def enable_change_email(view: Callable[P, R]): @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): diff --git a/api/libs/email_i18n.py b/api/libs/email_i18n.py index 3c039dff53..9dde87d800 100644 --- a/api/libs/email_i18n.py +++ b/api/libs/email_i18n.py @@ -21,6 +21,7 @@ class EmailType(Enum): """Enumeration of supported email types.""" RESET_PASSWORD = "reset_password" + RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST = "reset_password_when_account_not_exist" INVITE_MEMBER = "invite_member" EMAIL_CODE_LOGIN = "email_code_login" CHANGE_EMAIL_OLD = "change_email_old" @@ -34,6 +35,9 @@ class EmailType(Enum): ENTERPRISE_CUSTOM = "enterprise_custom" QUEUE_MONITOR_ALERT = "queue_monitor_alert" DOCUMENT_CLEAN_NOTIFY = "document_clean_notify" + EMAIL_REGISTER = "email_register" + EMAIL_REGISTER_WHEN_ACCOUNT_EXIST = "email_register_when_account_exist" + RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER = "reset_password_when_account_not_exist_no_register" class EmailLanguage(Enum): @@ -441,6 +445,54 @@ def create_default_email_config() -> EmailI18nConfig: branded_template_path="clean_document_job_mail_template_zh-CN.html", ), }, + EmailType.EMAIL_REGISTER: { + EmailLanguage.EN_US: EmailTemplate( + subject="Register Your {application_title} Account", + template_path="register_email_template_en-US.html", + branded_template_path="without-brand/register_email_template_en-US.html", + ), + EmailLanguage.ZH_HANS: EmailTemplate( + subject="注册您的 {application_title} 账户", + template_path="register_email_template_zh-CN.html", + branded_template_path="without-brand/register_email_template_zh-CN.html", + ), + }, + EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST: { + EmailLanguage.EN_US: EmailTemplate( + subject="Register Your {application_title} Account", + template_path="register_email_when_account_exist_template_en-US.html", + branded_template_path="without-brand/register_email_when_account_exist_template_en-US.html", + ), + EmailLanguage.ZH_HANS: EmailTemplate( + subject="注册您的 {application_title} 账户", + template_path="register_email_when_account_exist_template_zh-CN.html", + branded_template_path="without-brand/register_email_when_account_exist_template_zh-CN.html", + ), + }, + EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST: { + EmailLanguage.EN_US: EmailTemplate( + subject="Reset Your {application_title} Password", + template_path="reset_password_mail_when_account_not_exist_template_en-US.html", + branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_en-US.html", + ), + EmailLanguage.ZH_HANS: EmailTemplate( + subject="重置您的 {application_title} 密码", + template_path="reset_password_mail_when_account_not_exist_template_zh-CN.html", + branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html", + ), + }, + EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER: { + EmailLanguage.EN_US: EmailTemplate( + subject="Reset Your {application_title} Password", + template_path="reset_password_mail_when_account_not_exist_no_register_template_en-US.html", + branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html", + ), + EmailLanguage.ZH_HANS: EmailTemplate( + subject="重置您的 {application_title} 密码", + template_path="reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", + branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", + ), + }, } return EmailI18nConfig(templates=templates) diff --git a/api/services/account_service.py b/api/services/account_service.py index a76792f88e..8438423f2e 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -37,7 +37,6 @@ from services.billing_service import BillingService from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, - AccountNotFoundError, AccountNotLinkTenantError, AccountPasswordError, AccountRegisterError, @@ -65,7 +64,11 @@ from tasks.mail_owner_transfer_task import ( send_old_owner_transfer_notify_email_task, send_owner_transfer_confirm_task, ) -from tasks.mail_reset_password_task import send_reset_password_mail_task +from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist +from tasks.mail_reset_password_task import ( + send_reset_password_mail_task, + send_reset_password_mail_task_when_account_not_exist, +) logger = logging.getLogger(__name__) @@ -82,6 +85,7 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS) class AccountService: reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) + email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1 ) @@ -95,6 +99,7 @@ class AccountService: FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 + EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: @@ -171,7 +176,7 @@ class AccountService: account = db.session.query(Account).filter_by(email=email).first() if not account: - raise AccountNotFoundError() + raise AccountPasswordError("Invalid email or password.") if account.status == AccountStatus.BANNED.value: raise AccountLoginError("Account is banned.") @@ -433,6 +438,7 @@ class AccountService: account: Optional[Account] = None, email: Optional[str] = None, language: str = "en-US", + is_allow_register: bool = False, ): account_email = account.email if account else email if account_email is None: @@ -445,14 +451,54 @@ class AccountService: code, token = cls.generate_reset_password_token(account_email, account) - send_reset_password_mail_task.delay( - language=language, - to=account_email, - code=code, - ) + if account: + send_reset_password_mail_task.delay( + language=language, + to=account_email, + code=code, + ) + else: + send_reset_password_mail_task_when_account_not_exist.delay( + language=language, + to=account_email, + is_allow_register=is_allow_register, + ) cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token + @classmethod + def send_email_register_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: str = "en-US", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.email_register_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import EmailRegisterRateLimitExceededError + + raise EmailRegisterRateLimitExceededError() + + code, token = cls.generate_email_register_token(account_email) + + if account: + send_email_register_mail_task_when_account_exist.delay( + language=language, + to=account_email, + ) + + else: + send_email_register_mail_task.delay( + language=language, + to=account_email, + code=code, + ) + cls.email_register_rate_limiter.increment_rate_limit(account_email) + return token + @classmethod def send_change_email_email( cls, @@ -585,6 +631,19 @@ class AccountService: ) return code, token + @classmethod + def generate_email_register_token( + cls, + email: str, + code: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data) + return code, token + @classmethod def generate_change_email_token( cls, @@ -623,6 +682,10 @@ class AccountService: def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") + @classmethod + def revoke_email_register_token(cls, token: str): + TokenManager.revoke_token(token, "email_register") + @classmethod def revoke_change_email_token(cls, token: str): TokenManager.revoke_token(token, "change_email") @@ -635,6 +698,10 @@ class AccountService: def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") + @classmethod + def get_email_register_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "email_register") + @classmethod def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "change_email") @@ -742,6 +809,16 @@ class AccountService: count = int(count) + 1 redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) + @staticmethod + @redis_fallback(default_return=None) + def add_email_register_error_rate_limit(email: str) -> None: + key = f"email_register_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count) + @staticmethod @redis_fallback(default_return=False) def is_forgot_password_error_rate_limit(email: str) -> bool: @@ -761,6 +838,24 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) + @staticmethod + @redis_fallback(default_return=False) + def is_email_register_error_rate_limit(email: str) -> bool: + key = f"email_register_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_email_register_error_rate_limit(email: str): + key = f"email_register_error_rate_limit:{email}" + redis_client.delete(key) + @staticmethod @redis_fallback(default_return=None) def add_change_email_error_rate_limit(email: str): diff --git a/api/tasks/mail_register_task.py b/api/tasks/mail_register_task.py new file mode 100644 index 0000000000..acf2852649 --- /dev/null +++ b/api/tasks/mail_register_task.py @@ -0,0 +1,86 @@ +import logging +import time + +import click +from celery import shared_task + +from configs import dify_config +from extensions.ext_mail import mail +from libs.email_i18n import EmailType, get_email_i18n_service + +logger = logging.getLogger(__name__) + + +@shared_task(queue="mail") +def send_email_register_mail_task(language: str, to: str, code: str) -> None: + """ + Send email register email with internationalization support. + + Args: + language: Language code for email localization + to: Recipient email address + code: Email register code + """ + if not mail.is_inited(): + return + + logger.info(click.style(f"Start email register mail to {to}", fg="green")) + start_at = time.perf_counter() + + try: + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.EMAIL_REGISTER, + language_code=language, + to=to, + template_context={ + "to": to, + "code": code, + }, + ) + + end_at = time.perf_counter() + logger.info( + click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") + ) + except Exception: + logger.exception("Send email register mail to %s failed", to) + + +@shared_task(queue="mail") +def send_email_register_mail_task_when_account_exist(language: str, to: str) -> None: + """ + Send email register email with internationalization support when account exist. + + Args: + language: Language code for email localization + to: Recipient email address + """ + if not mail.is_inited(): + return + + logger.info(click.style(f"Start email register mail to {to}", fg="green")) + start_at = time.perf_counter() + + try: + login_url = f"{dify_config.CONSOLE_WEB_URL}/signin" + reset_password_url = f"{dify_config.CONSOLE_WEB_URL}/reset-password" + + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST, + language_code=language, + to=to, + template_context={ + "to": to, + "login_url": login_url, + "reset_password_url": reset_password_url, + }, + ) + + end_at = time.perf_counter() + logger.info( + click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") + ) + except Exception: + logger.exception("Send email register mail to %s failed", to) diff --git a/api/tasks/mail_reset_password_task.py b/api/tasks/mail_reset_password_task.py index 545db84fde..1739562588 100644 --- a/api/tasks/mail_reset_password_task.py +++ b/api/tasks/mail_reset_password_task.py @@ -4,6 +4,7 @@ import time import click from celery import shared_task +from configs import dify_config from extensions.ext_mail import mail from libs.email_i18n import EmailType, get_email_i18n_service @@ -44,3 +45,47 @@ def send_reset_password_mail_task(language: str, to: str, code: str): ) except Exception: logger.exception("Send password reset mail to %s failed", to) + + +@shared_task(queue="mail") +def send_reset_password_mail_task_when_account_not_exist(language: str, to: str, is_allow_register: bool) -> None: + """ + Send reset password email with internationalization support when account not exist. + + Args: + language: Language code for email localization + to: Recipient email address + """ + if not mail.is_inited(): + return + + logger.info(click.style(f"Start password reset mail to {to}", fg="green")) + start_at = time.perf_counter() + + try: + if is_allow_register: + sign_up_url = f"{dify_config.CONSOLE_WEB_URL}/signup" + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST, + language_code=language, + to=to, + template_context={ + "to": to, + "sign_up_url": sign_up_url, + }, + ) + else: + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER, + language_code=language, + to=to, + ) + + end_at = time.perf_counter() + logger.info( + click.style(f"Send password reset mail to {to} succeeded: latency: {end_at - start_at}", fg="green") + ) + except Exception: + logger.exception("Send password reset mail to %s failed", to) diff --git a/api/templates/register_email_template_en-US.html b/api/templates/register_email_template_en-US.html new file mode 100644 index 0000000000..e0fec59100 --- /dev/null +++ b/api/templates/register_email_template_en-US.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Dify Sign-up Code

+

Your sign-up code for Dify + + Copy and paste this code, this code will only be valid for the next 5 minutes.

+
+ {{code}} +
+

If you didn't request this code, don't worry. You can safely ignore this email.

+
+ + + \ No newline at end of file diff --git a/api/templates/register_email_template_zh-CN.html b/api/templates/register_email_template_zh-CN.html new file mode 100644 index 0000000000..3b507290f0 --- /dev/null +++ b/api/templates/register_email_template_zh-CN.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Dify 注册验证码

+

您的 Dify 注册验证码 + + 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。

+
+ {{code}} +
+

如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。

+
+ + + \ No newline at end of file diff --git a/api/templates/register_email_when_account_exist_template_en-US.html b/api/templates/register_email_when_account_exist_template_en-US.html new file mode 100644 index 0000000000..967f97a1b8 --- /dev/null +++ b/api/templates/register_email_when_account_exist_template_en-US.html @@ -0,0 +1,94 @@ + + + + + + + + +
+
+ + Dify Logo +
+

It looks like you’re signing up with an existing account

+

Hi, + We noticed you tried to sign up, but this email is already registered with an existing account. + + Please log in here:

+

+ Log In +

+

+ If you forgot your password, you can reset it here:

+

+ Reset Password +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/register_email_when_account_exist_template_zh-CN.html b/api/templates/register_email_when_account_exist_template_zh-CN.html new file mode 100644 index 0000000000..7d63ca06e8 --- /dev/null +++ b/api/templates/register_email_when_account_exist_template_zh-CN.html @@ -0,0 +1,95 @@ + + + + + + + + +
+
+ + Dify Logo +
+

您似乎正在使用现有账户注册

+

Hi, + 我们注意到您尝试注册,但此电子邮件已与现有账户注册。 + + 请在此登录:

+

+ 登录 +

+

+ 如果您忘记了密码,可以在此重置:

+

+ 重置密码 +

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html new file mode 100644 index 0000000000..c849057519 --- /dev/null +++ b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html @@ -0,0 +1,85 @@ + + + + + + + + +
+
+ + Dify Logo +
+

It looks like you’re resetting a password with an unregistered email

+

Hi, + We noticed you tried to reset your password, but this email is not associated with any account. +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html new file mode 100644 index 0000000000..51ed79cfbb --- /dev/null +++ b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html @@ -0,0 +1,84 @@ + + + + + + + + +
+
+ + Dify Logo +
+

看起来您正在使用未注册的电子邮件重置密码

+

Hi, + 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html b/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html new file mode 100644 index 0000000000..4ad82a2ccd --- /dev/null +++ b/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

It looks like you’re resetting a password with an unregistered email

+

Hi, + We noticed you tried to reset your password, but this email is not associated with any account. + + Please sign up here:

+

+ [Sign Up] +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html b/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html new file mode 100644 index 0000000000..284d700485 --- /dev/null +++ b/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

看起来您正在使用未注册的电子邮件重置密码

+

Hi, + 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 + + 请在此注册:

+

+ [注册] +

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/register_email_template_en-US.html b/api/templates/without-brand/register_email_template_en-US.html new file mode 100644 index 0000000000..65e179ef18 --- /dev/null +++ b/api/templates/without-brand/register_email_template_en-US.html @@ -0,0 +1,83 @@ + + + + + + + + +
+

{{application_title}} Sign-up Code

+

Your sign-up code for Dify + + Copy and paste this code, this code will only be valid for the next 5 minutes.

+
+ {{code}} +
+

If you didn't request this code, don't worry. You can safely ignore this email.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/register_email_template_zh-CN.html b/api/templates/without-brand/register_email_template_zh-CN.html new file mode 100644 index 0000000000..26df4760aa --- /dev/null +++ b/api/templates/without-brand/register_email_template_zh-CN.html @@ -0,0 +1,83 @@ + + + + + + + + +
+

{{application_title}} 注册验证码

+

您的 {{application_title}} 注册验证码 + + 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。

+
+ {{code}} +
+

如果您没有请求此验证码,请不要担心。您可以安全地忽略此电子邮件。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html new file mode 100644 index 0000000000..063d0de34c --- /dev/null +++ b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html @@ -0,0 +1,90 @@ + + + + + + + + +
+

It looks like you’re signing up with an existing account

+

Hi, + We noticed you tried to sign up, but this email is already registered with an existing account. + + Please log in here:

+

+ Log In +

+

+ If you forgot your password, you can reset it here:

+

+ Reset Password +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html new file mode 100644 index 0000000000..3edbd25e87 --- /dev/null +++ b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html @@ -0,0 +1,91 @@ + + + + + + + + +
+

您似乎正在使用现有账户注册

+

Hi, + 我们注意到您尝试注册,但此电子邮件已与现有账户注册。 + + 请在此登录:

+

+ 登录 +

+

+ 如果您忘记了密码,可以在此重置:

+

+ 重置密码 +

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html new file mode 100644 index 0000000000..5e6d2f1671 --- /dev/null +++ b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html @@ -0,0 +1,81 @@ + + + + + + + + +
+

It looks like you’re resetting a password with an unregistered email

+

Hi, + We noticed you tried to reset your password, but this email is not associated with any account. +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html new file mode 100644 index 0000000000..fd53becef6 --- /dev/null +++ b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html @@ -0,0 +1,81 @@ + + + + + + + + +
+

看起来您正在使用未注册的电子邮件重置密码

+

Hi, + 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 +

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html new file mode 100644 index 0000000000..c67400593f --- /dev/null +++ b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

It looks like you’re resetting a password with an unregistered email

+

Hi, + We noticed you tried to reset your password, but this email is not associated with any account. + + Please sign up here:

+

+ [Sign Up] +

+

If you didn’t request this action, you can safely ignore this email. + Need help? Feel free to contact us at support@dify.ai.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html new file mode 100644 index 0000000000..bfd0272831 --- /dev/null +++ b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

看起来您正在使用未注册的电子邮件重置密码

+

Hi, + 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 + + 请在此注册:

+

+ [注册] +

+

如果您没有请求此操作,您可以安全地忽略此电子邮件。 + 需要帮助?随时联系我们 at support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 2e98dec964..92df93fb13 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -203,6 +203,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 415e65ce51..fef353b0e2 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -13,7 +13,6 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, - AccountNotFoundError, AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, @@ -139,7 +138,7 @@ class TestAccountService: fake = Faker() email = fake.email() password = fake.password(length=12) - with pytest.raises(AccountNotFoundError): + with pytest.raises(AccountPasswordError): AccountService.authenticate(email, password) def test_authenticate_banned_account(self, db_session_with_containers, mock_external_service_dependencies): diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index aefb4bf8b0..b6697ac5d4 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -9,7 +9,6 @@ from flask_restx import Api import services.errors.account from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi -from controllers.console.error import AccountNotFound class TestAuthenticationSecurity: @@ -27,31 +26,33 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.FeatureService.get_system_features") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") - @patch("controllers.console.auth.login.AccountService.send_reset_password_email") + @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") def test_login_invalid_email_with_registration_allowed( - self, mock_get_invitation, mock_send_email, mock_authenticate, mock_is_rate_limit, mock_features, mock_db + self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db ): - """Test that invalid email sends reset password email when registration is allowed.""" + """Test that invalid email raises AuthenticationFailedError when account not found.""" # Arrange mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None - mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") + mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = True - mock_send_email.return_value = "token123" # Act with self.app.test_request_context( "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} ): login_api = LoginApi() - result = login_api.post() - # Assert - assert result == {"result": "fail", "data": "token123", "code": "account_not_found"} - mock_send_email.assert_called_once_with(email="nonexistent@example.com", language="en-US") + # Assert + with pytest.raises(AuthenticationFailedError) as exc_info: + login_api.post() + + assert exc_info.value.error_code == "authentication_failed" + assert exc_info.value.description == "Invalid email or password." + mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @@ -87,16 +88,17 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.FeatureService.get_system_features") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") + @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") def test_login_invalid_email_with_registration_disabled( - self, mock_get_invitation, mock_authenticate, mock_is_rate_limit, mock_features, mock_db + self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db ): - """Test that invalid email raises AccountNotFound when registration is disabled.""" + """Test that invalid email raises AuthenticationFailedError when account not found.""" # Arrange mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None - mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") + mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = False @@ -107,10 +109,12 @@ class TestAuthenticationSecurity: login_api = LoginApi() # Assert - with pytest.raises(AccountNotFound) as exc_info: + with pytest.raises(AuthenticationFailedError) as exc_info: login_api.post() - assert exc_info.value.error_code == "account_not_found" + assert exc_info.value.error_code == "authentication_failed" + assert exc_info.value.description == "Invalid email or password." + mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.FeatureService.get_system_features") diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 442839e44e..ed70a7b0de 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -10,7 +10,6 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, - AccountNotFoundError, AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, @@ -195,7 +194,7 @@ class TestAccountService: # Execute test and verify exception self._assert_exception_raised( - AccountNotFoundError, AccountService.authenticate, "notfound@example.com", "password" + AccountPasswordError, AccountService.authenticate, "notfound@example.com", "password" ) def test_authenticate_account_banned(self, mock_db_dependencies): diff --git a/docker/.env.example b/docker/.env.example index 96ad09ab99..8f4037b7d7 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -843,6 +843,7 @@ INVITE_EXPIRY_HOURS=72 # Reset password token valid time (minutes), RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 9774df3df5..058741825b 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -372,6 +372,7 @@ x-shared-env: &shared-api-worker-env INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} + EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5} CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} From aff248243663faad5c14994a6810acc193dce5de Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:55:57 +0800 Subject: [PATCH 06/10] Feature add test containers batch create segment to index (#25306) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- ...test_batch_create_segment_to_index_task.py | 734 ++++++++++++++++++ 1 file changed, 734 insertions(+) create mode 100644 api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py new file mode 100644 index 0000000000..b77975c032 --- /dev/null +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py @@ -0,0 +1,734 @@ +""" +Integration tests for batch_create_segment_to_index_task using testcontainers. + +This module provides comprehensive integration tests for the batch segment creation +and indexing task using TestContainers infrastructure. The tests ensure that the +task properly processes CSV files, creates document segments, and establishes +vector indexes in a real database environment. + +All tests use the testcontainers infrastructure to ensure proper database isolation +and realistic testing scenarios with actual PostgreSQL and Redis instances. +""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from faker import Faker + +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.dataset import Dataset, Document, DocumentSegment +from models.enums import CreatorUserRole +from models.model import UploadFile +from tasks.batch_create_segment_to_index_task import batch_create_segment_to_index_task + + +class TestBatchCreateSegmentToIndexTask: + """Integration tests for batch_create_segment_to_index_task using testcontainers.""" + + @pytest.fixture(autouse=True) + def cleanup_database(self, db_session_with_containers): + """Clean up database before each test to ensure isolation.""" + from extensions.ext_database import db + from extensions.ext_redis import redis_client + + # Clear all test data + db.session.query(DocumentSegment).delete() + db.session.query(Document).delete() + db.session.query(Dataset).delete() + db.session.query(UploadFile).delete() + db.session.query(TenantAccountJoin).delete() + db.session.query(Tenant).delete() + db.session.query(Account).delete() + db.session.commit() + + # Clear Redis cache + redis_client.flushdb() + + @pytest.fixture + def mock_external_service_dependencies(self): + """Mock setup for external service dependencies.""" + with ( + patch("tasks.batch_create_segment_to_index_task.storage") as mock_storage, + patch("tasks.batch_create_segment_to_index_task.ModelManager") as mock_model_manager, + patch("tasks.batch_create_segment_to_index_task.VectorService") as mock_vector_service, + ): + # Setup default mock returns + mock_storage.download.return_value = None + + # Mock embedding model for high quality indexing + mock_embedding_model = MagicMock() + mock_embedding_model.get_text_embedding_num_tokens.return_value = [10, 15, 20] + mock_model_manager_instance = MagicMock() + mock_model_manager_instance.get_model_instance.return_value = mock_embedding_model + mock_model_manager.return_value = mock_model_manager_instance + + # Mock vector service + mock_vector_service.create_segments_vector.return_value = None + + yield { + "storage": mock_storage, + "model_manager": mock_model_manager, + "vector_service": mock_vector_service, + "embedding_model": mock_embedding_model, + } + + def _create_test_account_and_tenant(self, db_session_with_containers): + """ + Helper method to create a test account and tenant for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + + Returns: + tuple: (Account, Tenant) created instances + """ + fake = Faker() + + # Create account + account = Account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + status="active", + ) + + from extensions.ext_database import db + + db.session.add(account) + db.session.commit() + + # Create tenant for the account + tenant = Tenant( + name=fake.company(), + status="normal", + ) + db.session.add(tenant) + db.session.commit() + + # Create tenant-account join + join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER.value, + current=True, + ) + db.session.add(join) + db.session.commit() + + # Set current tenant for account + account.current_tenant = tenant + + return account, tenant + + def _create_test_dataset(self, db_session_with_containers, account, tenant): + """ + Helper method to create a test dataset for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + account: Account instance + tenant: Tenant instance + + Returns: + Dataset: Created dataset instance + """ + fake = Faker() + + dataset = Dataset( + tenant_id=tenant.id, + name=fake.company(), + description=fake.text(), + data_source_type="upload_file", + indexing_technique="high_quality", + embedding_model="text-embedding-ada-002", + embedding_model_provider="openai", + created_by=account.id, + ) + + from extensions.ext_database import db + + db.session.add(dataset) + db.session.commit() + + return dataset + + def _create_test_document(self, db_session_with_containers, account, tenant, dataset): + """ + Helper method to create a test document for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + account: Account instance + tenant: Tenant instance + dataset: Dataset instance + + Returns: + Document: Created document instance + """ + fake = Faker() + + document = Document( + tenant_id=tenant.id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="test_batch", + name=fake.file_name(), + created_from="upload_file", + created_by=account.id, + indexing_status="completed", + enabled=True, + archived=False, + doc_form="text_model", + word_count=0, + ) + + from extensions.ext_database import db + + db.session.add(document) + db.session.commit() + + return document + + def _create_test_upload_file(self, db_session_with_containers, account, tenant): + """ + Helper method to create a test upload file for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + account: Account instance + tenant: Tenant instance + + Returns: + UploadFile: Created upload file instance + """ + fake = Faker() + + upload_file = UploadFile( + tenant_id=tenant.id, + storage_type="local", + key=f"test_files/{fake.file_name()}", + name=fake.file_name(), + size=1024, + extension=".csv", + mime_type="text/csv", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=account.id, + created_at=datetime.now(), + used=False, + ) + + from extensions.ext_database import db + + db.session.add(upload_file) + db.session.commit() + + return upload_file + + def _create_test_csv_content(self, content_type="text_model"): + """ + Helper method to create test CSV content. + + Args: + content_type: Type of content to create ("text_model" or "qa_model") + + Returns: + str: CSV content as string + """ + if content_type == "qa_model": + csv_content = "content,answer\n" + csv_content += "This is the first segment content,This is the first answer\n" + csv_content += "This is the second segment content,This is the second answer\n" + csv_content += "This is the third segment content,This is the third answer\n" + else: + csv_content = "content\n" + csv_content += "This is the first segment content\n" + csv_content += "This is the second segment content\n" + csv_content += "This is the third segment content\n" + + return csv_content + + def test_batch_create_segment_to_index_task_success_text_model( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test successful batch creation of segments for text model documents. + + This test verifies that the task can successfully: + 1. Process a CSV file with text content + 2. Create document segments with proper metadata + 3. Update document word count + 4. Create vector indexes + 5. Set Redis cache status + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + document = self._create_test_document(db_session_with_containers, account, tenant, dataset) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Create CSV content + csv_content = self._create_test_csv_content("text_model") + + # Mock storage to return our CSV content + mock_storage = mock_external_service_dependencies["storage"] + + def mock_download(key, file_path): + with open(file_path, "w", encoding="utf-8") as f: + f.write(csv_content) + + mock_storage.download.side_effect = mock_download + + # Execute the task + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=dataset.id, + document_id=document.id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify results + from extensions.ext_database import db + + # Check that segments were created + segments = db.session.query(DocumentSegment).filter_by(document_id=document.id).all() + assert len(segments) == 3 + + # Verify segment content and metadata + for i, segment in enumerate(segments): + assert segment.tenant_id == tenant.id + assert segment.dataset_id == dataset.id + assert segment.document_id == document.id + assert segment.position == i + 1 + assert segment.status == "completed" + assert segment.indexing_at is not None + assert segment.completed_at is not None + assert segment.answer is None # text_model doesn't have answers + + # Check that document word count was updated + db.session.refresh(document) + assert document.word_count > 0 + + # Verify vector service was called + mock_vector_service = mock_external_service_dependencies["vector_service"] + mock_vector_service.create_segments_vector.assert_called_once() + + # Check Redis cache was set + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"completed" + + def test_batch_create_segment_to_index_task_dataset_not_found( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test task failure when dataset does not exist. + + This test verifies that the task properly handles error cases: + 1. Fails gracefully when dataset is not found + 2. Sets appropriate Redis cache status + 3. Logs error information + 4. Maintains database integrity + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Use non-existent IDs + non_existent_dataset_id = str(uuid.uuid4()) + non_existent_document_id = str(uuid.uuid4()) + + # Execute the task with non-existent dataset + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=non_existent_dataset_id, + document_id=non_existent_document_id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify error handling + # Check Redis cache was set to error status + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"error" + + # Verify no segments were created (since dataset doesn't exist) + from extensions.ext_database import db + + segments = db.session.query(DocumentSegment).all() + assert len(segments) == 0 + + # Verify no documents were modified + documents = db.session.query(Document).all() + assert len(documents) == 0 + + def test_batch_create_segment_to_index_task_document_not_found( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test task failure when document does not exist. + + This test verifies that the task properly handles error cases: + 1. Fails gracefully when document is not found + 2. Sets appropriate Redis cache status + 3. Maintains database integrity + 4. Logs appropriate error information + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Use non-existent document ID + non_existent_document_id = str(uuid.uuid4()) + + # Execute the task with non-existent document + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=dataset.id, + document_id=non_existent_document_id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify error handling + # Check Redis cache was set to error status + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"error" + + # Verify no segments were created + from extensions.ext_database import db + + segments = db.session.query(DocumentSegment).all() + assert len(segments) == 0 + + # Verify dataset remains unchanged (no segments were added to the dataset) + db.session.refresh(dataset) + segments_for_dataset = db.session.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + assert len(segments_for_dataset) == 0 + + def test_batch_create_segment_to_index_task_document_not_available( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test task failure when document is not available for indexing. + + This test verifies that the task properly handles error cases: + 1. Fails when document is disabled + 2. Fails when document is archived + 3. Fails when document indexing status is not completed + 4. Sets appropriate Redis cache status + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Create document with various unavailable states + test_cases = [ + # Disabled document + Document( + tenant_id=tenant.id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="test_batch", + name="disabled_document", + created_from="upload_file", + created_by=account.id, + indexing_status="completed", + enabled=False, # Document is disabled + archived=False, + doc_form="text_model", + word_count=0, + ), + # Archived document + Document( + tenant_id=tenant.id, + dataset_id=dataset.id, + position=2, + data_source_type="upload_file", + batch="test_batch", + name="archived_document", + created_from="upload_file", + created_by=account.id, + indexing_status="completed", + enabled=True, + archived=True, # Document is archived + doc_form="text_model", + word_count=0, + ), + # Document with incomplete indexing + Document( + tenant_id=tenant.id, + dataset_id=dataset.id, + position=3, + data_source_type="upload_file", + batch="test_batch", + name="incomplete_document", + created_from="upload_file", + created_by=account.id, + indexing_status="indexing", # Not completed + enabled=True, + archived=False, + doc_form="text_model", + word_count=0, + ), + ] + + from extensions.ext_database import db + + for document in test_cases: + db.session.add(document) + db.session.commit() + + # Test each unavailable document + for i, document in enumerate(test_cases): + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=dataset.id, + document_id=document.id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify error handling for each case + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"error" + + # Verify no segments were created + segments = db.session.query(DocumentSegment).filter_by(document_id=document.id).all() + assert len(segments) == 0 + + def test_batch_create_segment_to_index_task_upload_file_not_found( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test task failure when upload file does not exist. + + This test verifies that the task properly handles error cases: + 1. Fails gracefully when upload file is not found + 2. Sets appropriate Redis cache status + 3. Maintains database integrity + 4. Logs appropriate error information + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + document = self._create_test_document(db_session_with_containers, account, tenant, dataset) + + # Use non-existent upload file ID + non_existent_upload_file_id = str(uuid.uuid4()) + + # Execute the task with non-existent upload file + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=non_existent_upload_file_id, + dataset_id=dataset.id, + document_id=document.id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify error handling + # Check Redis cache was set to error status + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"error" + + # Verify no segments were created + from extensions.ext_database import db + + segments = db.session.query(DocumentSegment).all() + assert len(segments) == 0 + + # Verify document remains unchanged + db.session.refresh(document) + assert document.word_count == 0 + + def test_batch_create_segment_to_index_task_empty_csv_file( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test task failure when CSV file is empty. + + This test verifies that the task properly handles error cases: + 1. Fails when CSV file contains no data + 2. Sets appropriate Redis cache status + 3. Maintains database integrity + 4. Logs appropriate error information + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + document = self._create_test_document(db_session_with_containers, account, tenant, dataset) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Create empty CSV content + empty_csv_content = "content\n" # Only header, no data rows + + # Mock storage to return empty CSV content + mock_storage = mock_external_service_dependencies["storage"] + + def mock_download(key, file_path): + with open(file_path, "w", encoding="utf-8") as f: + f.write(empty_csv_content) + + mock_storage.download.side_effect = mock_download + + # Execute the task + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=dataset.id, + document_id=document.id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify error handling + # Check Redis cache was set to error status + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"error" + + # Verify no segments were created + from extensions.ext_database import db + + segments = db.session.query(DocumentSegment).all() + assert len(segments) == 0 + + # Verify document remains unchanged + db.session.refresh(document) + assert document.word_count == 0 + + def test_batch_create_segment_to_index_task_position_calculation( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test proper position calculation for segments when existing segments exist. + + This test verifies that the task correctly: + 1. Calculates positions for new segments based on existing ones + 2. Handles position increment logic properly + 3. Maintains proper segment ordering + 4. Works with existing segment data + """ + # Create test data + account, tenant = self._create_test_account_and_tenant(db_session_with_containers) + dataset = self._create_test_dataset(db_session_with_containers, account, tenant) + document = self._create_test_document(db_session_with_containers, account, tenant, dataset) + upload_file = self._create_test_upload_file(db_session_with_containers, account, tenant) + + # Create existing segments to test position calculation + existing_segments = [] + for i in range(3): + segment = DocumentSegment( + tenant_id=tenant.id, + dataset_id=dataset.id, + document_id=document.id, + position=i + 1, + content=f"Existing segment {i + 1}", + word_count=len(f"Existing segment {i + 1}"), + tokens=10, + created_by=account.id, + status="completed", + index_node_id=str(uuid.uuid4()), + index_node_hash=f"hash_{i}", + ) + existing_segments.append(segment) + + from extensions.ext_database import db + + for segment in existing_segments: + db.session.add(segment) + db.session.commit() + + # Create CSV content + csv_content = self._create_test_csv_content("text_model") + + # Mock storage to return our CSV content + mock_storage = mock_external_service_dependencies["storage"] + + def mock_download(key, file_path): + with open(file_path, "w", encoding="utf-8") as f: + f.write(csv_content) + + mock_storage.download.side_effect = mock_download + + # Execute the task + job_id = str(uuid.uuid4()) + batch_create_segment_to_index_task( + job_id=job_id, + upload_file_id=upload_file.id, + dataset_id=dataset.id, + document_id=document.id, + tenant_id=tenant.id, + user_id=account.id, + ) + + # Verify results + # Check that new segments were created with correct positions + all_segments = ( + db.session.query(DocumentSegment) + .filter_by(document_id=document.id) + .order_by(DocumentSegment.position) + .all() + ) + assert len(all_segments) == 6 # 3 existing + 3 new + + # Verify position ordering + for i, segment in enumerate(all_segments): + assert segment.position == i + 1 + + # Verify new segments have correct positions (4, 5, 6) + new_segments = all_segments[3:] + for i, segment in enumerate(new_segments): + expected_position = 4 + i # Should start at position 4 + assert segment.position == expected_position + assert segment.status == "completed" + assert segment.indexing_at is not None + assert segment.completed_at is not None + + # Check that document word count was updated + db.session.refresh(document) + assert document.word_count > 0 + + # Verify vector service was called + mock_vector_service = mock_external_service_dependencies["vector_service"] + mock_vector_service.create_segments_vector.assert_called_once() + + # Check Redis cache was set + from extensions.ext_redis import redis_client + + cache_key = f"segment_batch_import_{job_id}" + cache_value = redis_client.get(cache_key) + assert cache_value == b"completed" From a9324133144b52793cb3e1b53b67700718bc1ceb Mon Sep 17 00:00:00 2001 From: "Debin.Meng" Date: Mon, 8 Sep 2025 18:00:33 +0800 Subject: [PATCH 07/10] fix: Incorrect URL Parameter Parsing Causes user_id Retrieval Error (#25261) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- web/app/components/base/chat/utils.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/app/components/base/chat/utils.ts b/web/app/components/base/chat/utils.ts index 1c478747c5..34df617afe 100644 --- a/web/app/components/base/chat/utils.ts +++ b/web/app/components/base/chat/utils.ts @@ -43,6 +43,16 @@ async function getProcessedInputsFromUrlParams(): Promise> { async function getProcessedSystemVariablesFromUrlParams(): Promise> { const urlParams = new URLSearchParams(window.location.search) + const redirectUrl = urlParams.get('redirect_url') + if (redirectUrl) { + const decodedRedirectUrl = decodeURIComponent(redirectUrl) + const queryString = decodedRedirectUrl.split('?')[1] + if (queryString) { + const redirectParams = new URLSearchParams(queryString) + for (const [key, value] of redirectParams.entries()) + urlParams.set(key, value) + } + } const systemVariables: Record = {} const entriesArray = Array.from(urlParams.entries()) await Promise.all( From 598ec07c911785321813ff6c030b5cafbe8d0728 Mon Sep 17 00:00:00 2001 From: kenwoodjw Date: Mon, 8 Sep 2025 18:03:24 +0800 Subject: [PATCH 08/10] feat: enable dsl export encrypt dataset id or not (#25102) Signed-off-by: kenwoodjw --- api/.env.example | 4 ++++ api/configs/feature/__init__.py | 5 +++++ api/services/app_dsl_service.py | 32 +++++++++++++++++++++++++++++--- docker/.env.example | 10 ++++++++++ docker/docker-compose.yaml | 1 + 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/api/.env.example b/api/.env.example index 76f4c505f5..2986402e9e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -570,3 +570,7 @@ QUEUE_MONITOR_INTERVAL=30 # Swagger UI configuration SWAGGER_UI_ENABLED=true SWAGGER_UI_PATH=/swagger-ui.html + +# Whether to encrypt dataset IDs when exporting DSL files (default: true) +# Set to false to export dataset IDs as plain text for easier cross-environment import +DSL_EXPORT_ENCRYPT_DATASET_ID=true diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index d6dc9710fb..0d6f4e416e 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -807,6 +807,11 @@ class DataSetConfig(BaseSettings): default=30, ) + DSL_EXPORT_ENCRYPT_DATASET_ID: bool = Field( + description="Enable or disable dataset ID encryption when exporting DSL files", + default=True, + ) + class WorkspaceConfig(BaseSettings): """ diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 2344be0aaf..2ed73ffec1 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session +from configs import dify_config from core.helper import ssrf_proxy from core.model_runtime.utils.encoders import jsonable_encoder from core.plugin.entities.plugin import PluginDependency @@ -786,7 +787,10 @@ class AppDslService: @classmethod def encrypt_dataset_id(cls, dataset_id: str, tenant_id: str) -> str: - """Encrypt dataset_id using AES-CBC mode""" + """Encrypt dataset_id using AES-CBC mode or return plain text based on configuration""" + if not dify_config.DSL_EXPORT_ENCRYPT_DATASET_ID: + return dataset_id + key = cls._generate_aes_key(tenant_id) iv = key[:16] cipher = AES.new(key, AES.MODE_CBC, iv) @@ -795,12 +799,34 @@ class AppDslService: @classmethod def decrypt_dataset_id(cls, encrypted_data: str, tenant_id: str) -> str | None: - """AES decryption""" + """AES decryption with fallback to plain text UUID""" + # First, check if it's already a plain UUID (not encrypted) + if cls._is_valid_uuid(encrypted_data): + return encrypted_data + + # If it's not a UUID, try to decrypt it try: key = cls._generate_aes_key(tenant_id) iv = key[:16] cipher = AES.new(key, AES.MODE_CBC, iv) pt = unpad(cipher.decrypt(base64.b64decode(encrypted_data)), AES.block_size) - return pt.decode() + decrypted_text = pt.decode() + + # Validate that the decrypted result is a valid UUID + if cls._is_valid_uuid(decrypted_text): + return decrypted_text + else: + # If decrypted result is not a valid UUID, it's probably not our encrypted data + return None except Exception: + # If decryption fails completely, return None return None + + @staticmethod + def _is_valid_uuid(value: str) -> bool: + """Check if string is a valid UUID format""" + try: + uuid.UUID(value) + return True + except (ValueError, TypeError): + return False diff --git a/docker/.env.example b/docker/.env.example index 8f4037b7d7..92347a6e76 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -908,6 +908,12 @@ WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 HTTP_REQUEST_NODE_SSL_VERIFY=True +# Base64 encoded CA certificate data for custom certificate verification (PEM format, optional) +# HTTP_REQUEST_NODE_SSL_CERT_DATA=LS0tLS1CRUdJTi... +# Base64 encoded client certificate data for mutual TLS authentication (PEM format, optional) +# HTTP_REQUEST_NODE_SSL_CLIENT_CERT_DATA=LS0tLS1CRUdJTi... +# Base64 encoded client private key data for mutual TLS authentication (PEM format, optional) +# HTTP_REQUEST_NODE_SSL_CLIENT_KEY_DATA=LS0tLS1CRUdJTi... # Respect X-* headers to redirect clients RESPECT_XFORWARD_HEADERS_ENABLED=false @@ -1261,6 +1267,10 @@ QUEUE_MONITOR_INTERVAL=30 SWAGGER_UI_ENABLED=true SWAGGER_UI_PATH=/swagger-ui.html +# Whether to encrypt dataset IDs when exporting DSL files (default: true) +# Set to false to export dataset IDs as plain text for easier cross-environment import +DSL_EXPORT_ENCRYPT_DATASET_ID=true + # Celery schedule tasks configuration ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false ENABLE_CLEAN_UNUSED_DATASETS_TASK=false diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 058741825b..193157b54f 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -571,6 +571,7 @@ x-shared-env: &shared-api-worker-env QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30} SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true} SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html} + DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true} ENABLE_CLEAN_EMBEDDING_CACHE_TASK: ${ENABLE_CLEAN_EMBEDDING_CACHE_TASK:-false} ENABLE_CLEAN_UNUSED_DATASETS_TASK: ${ENABLE_CLEAN_UNUSED_DATASETS_TASK:-false} ENABLE_CREATE_TIDB_SERVERLESS_TASK: ${ENABLE_CREATE_TIDB_SERVERLESS_TASK:-false} From ea61420441b9e1141ab6f4120bc1ca6b57fd7962 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 8 Sep 2025 19:20:09 +0800 Subject: [PATCH 09/10] Revert "feat: email register refactor" (#25367) --- api/.env.example | 1 - api/configs/feature/__init__.py | 11 -- api/controllers/console/__init__.py | 11 +- .../console/auth/email_register.py | 154 ------------------ api/controllers/console/auth/error.py | 12 -- .../console/auth/forgot_password.py | 39 ++++- api/controllers/console/auth/login.py | 28 +++- api/controllers/console/wraps.py | 13 -- api/libs/email_i18n.py | 52 ------ api/services/account_service.py | 111 +------------ api/tasks/mail_register_task.py | 86 ---------- api/tasks/mail_reset_password_task.py | 45 ----- .../register_email_template_en-US.html | 87 ---------- .../register_email_template_zh-CN.html | 87 ---------- ...ail_when_account_exist_template_en-US.html | 94 ----------- ...ail_when_account_exist_template_zh-CN.html | 95 ----------- ..._not_exist_no_register_template_en-US.html | 85 ---------- ..._not_exist_no_register_template_zh-CN.html | 84 ---------- ...when_account_not_exist_template_en-US.html | 89 ---------- ...when_account_not_exist_template_zh-CN.html | 89 ---------- .../register_email_template_en-US.html | 83 ---------- .../register_email_template_zh-CN.html | 83 ---------- ...ail_when_account_exist_template_en-US.html | 90 ---------- ...ail_when_account_exist_template_zh-CN.html | 91 ----------- ..._not_exist_no_register_template_en-US.html | 81 --------- ..._not_exist_no_register_template_zh-CN.html | 81 --------- ...when_account_not_exist_template_en-US.html | 85 ---------- ...when_account_not_exist_template_zh-CN.html | 85 ---------- api/tests/integration_tests/.env.example | 1 - .../services/test_account_service.py | 3 +- .../auth/test_authentication_security.py | 34 ++-- .../services/test_account_service.py | 3 +- docker/.env.example | 1 - docker/docker-compose.yaml | 1 - 34 files changed, 79 insertions(+), 1916 deletions(-) delete mode 100644 api/controllers/console/auth/email_register.py delete mode 100644 api/tasks/mail_register_task.py delete mode 100644 api/templates/register_email_template_en-US.html delete mode 100644 api/templates/register_email_template_zh-CN.html delete mode 100644 api/templates/register_email_when_account_exist_template_en-US.html delete mode 100644 api/templates/register_email_when_account_exist_template_zh-CN.html delete mode 100644 api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html delete mode 100644 api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html delete mode 100644 api/templates/reset_password_mail_when_account_not_exist_template_en-US.html delete mode 100644 api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html delete mode 100644 api/templates/without-brand/register_email_template_en-US.html delete mode 100644 api/templates/without-brand/register_email_template_zh-CN.html delete mode 100644 api/templates/without-brand/register_email_when_account_exist_template_en-US.html delete mode 100644 api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html delete mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html delete mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html delete mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html delete mode 100644 api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html diff --git a/api/.env.example b/api/.env.example index 2986402e9e..8d783af134 100644 --- a/api/.env.example +++ b/api/.env.example @@ -530,7 +530,6 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 -EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 0d6f4e416e..899fecea7c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -31,12 +31,6 @@ class SecurityConfig(BaseSettings): description="Duration in minutes for which a password reset token remains valid", default=5, ) - - EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( - description="Duration in minutes for which a email register token remains valid", - default=5, - ) - CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( description="Duration in minutes for which a change email token remains valid", default=5, @@ -645,11 +639,6 @@ class AuthConfig(BaseSettings): default=86400, ) - EMAIL_REGISTER_LOCKOUT_DURATION: PositiveInt = Field( - description="Time (in seconds) a user must wait before retrying email register after exceeding the rate limit.", - default=86400, - ) - class ModerationConfig(BaseSettings): """ diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 9634f3ca17..5ad7645969 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -70,16 +70,7 @@ from .app import ( ) # Import auth controllers -from .auth import ( - activate, - data_source_bearer_auth, - data_source_oauth, - email_register, - forgot_password, - login, - oauth, - oauth_server, -) +from .auth import activate, data_source_bearer_auth, data_source_oauth, forgot_password, login, oauth, oauth_server # Import billing controllers from .billing import billing, compliance diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py deleted file mode 100644 index 458e70c8de..0000000000 --- a/api/controllers/console/auth/email_register.py +++ /dev/null @@ -1,154 +0,0 @@ -from flask import request -from flask_restx import Resource, reqparse -from sqlalchemy import select -from sqlalchemy.orm import Session - -from constants.languages import languages -from controllers.console import api -from controllers.console.auth.error import ( - EmailAlreadyInUseError, - EmailCodeError, - EmailRegisterLimitError, - InvalidEmailError, - InvalidTokenError, - PasswordMismatchError, -) -from controllers.console.error import AccountInFreezeError, EmailSendIpLimitError -from controllers.console.wraps import email_password_login_enabled, email_register_enabled, setup_required -from extensions.ext_database import db -from libs.helper import email, extract_remote_ip -from libs.password import valid_password -from models.account import Account -from services.account_service import AccountService -from services.errors.account import AccountRegisterError -from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError - - -class EmailRegisterSendEmailApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled - def post(self): - parser = reqparse.RequestParser() - parser.add_argument("email", type=email, required=True, location="json") - parser.add_argument("language", type=str, required=False, location="json") - args = parser.parse_args() - - ip_address = extract_remote_ip(request) - if AccountService.is_email_send_ip_limit(ip_address): - raise EmailSendIpLimitError() - - if args["language"] is not None and args["language"] == "zh-Hans": - language = "zh-Hans" - else: - language = "en-US" - - with Session(db.engine) as session: - account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() - token = None - token = AccountService.send_email_register_email(email=args["email"], account=account, language=language) - return {"result": "success", "data": token} - - -class EmailRegisterCheckApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled - def post(self): - parser = reqparse.RequestParser() - parser.add_argument("email", type=str, required=True, location="json") - parser.add_argument("code", type=str, required=True, location="json") - parser.add_argument("token", type=str, required=True, nullable=False, location="json") - args = parser.parse_args() - - user_email = args["email"] - - is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(args["email"]) - if is_email_register_error_rate_limit: - raise EmailRegisterLimitError() - - token_data = AccountService.get_email_register_data(args["token"]) - if token_data is None: - raise InvalidTokenError() - - if user_email != token_data.get("email"): - raise InvalidEmailError() - - if args["code"] != token_data.get("code"): - AccountService.add_email_register_error_rate_limit(args["email"]) - raise EmailCodeError() - - # Verified, revoke the first token - AccountService.revoke_email_register_token(args["token"]) - - # Refresh token data by generating a new token - _, new_token = AccountService.generate_email_register_token( - user_email, code=args["code"], additional_data={"phase": "register"} - ) - - AccountService.reset_email_register_error_rate_limit(args["email"]) - return {"is_valid": True, "email": token_data.get("email"), "token": new_token} - - -class EmailRegisterResetApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled - def post(self): - parser = reqparse.RequestParser() - parser.add_argument("token", type=str, required=True, nullable=False, location="json") - parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json") - parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json") - args = parser.parse_args() - - # Validate passwords match - if args["new_password"] != args["password_confirm"]: - raise PasswordMismatchError() - - # Validate token and get register data - register_data = AccountService.get_email_register_data(args["token"]) - if not register_data: - raise InvalidTokenError() - # Must use token in reset phase - if register_data.get("phase", "") != "register": - raise InvalidTokenError() - - # Revoke token to prevent reuse - AccountService.revoke_email_register_token(args["token"]) - - email = register_data.get("email", "") - - with Session(db.engine) as session: - account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none() - - if account: - raise EmailAlreadyInUseError() - else: - account = self._create_new_account(email, args["password_confirm"]) - token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) - AccountService.reset_login_error_rate_limit(email) - - return {"result": "success", "data": token_pair.model_dump()} - - def _create_new_account(self, email, password): - # Create new account if allowed - try: - account = AccountService.create_account_and_tenant( - email=email, - name=email, - password=password, - interface_language=languages[0], - ) - except WorkSpaceNotAllowedCreateError: - pass - except WorkspacesLimitExceededError: - pass - except AccountRegisterError: - raise AccountInFreezeError() - - return account - - -api.add_resource(EmailRegisterSendEmailApi, "/email-register/send-email") -api.add_resource(EmailRegisterCheckApi, "/email-register/validity") -api.add_resource(EmailRegisterResetApi, "/email-register") diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 9cda8c90b1..7853bef917 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -31,12 +31,6 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 -class EmailRegisterRateLimitExceededError(BaseHTTPException): - error_code = "email_register_rate_limit_exceeded" - description = "Too many email register emails have been sent. Please try again in 1 minute." - code = 429 - - class EmailChangeRateLimitExceededError(BaseHTTPException): error_code = "email_change_rate_limit_exceeded" description = "Too many email change emails have been sent. Please try again in 1 minute." @@ -91,12 +85,6 @@ class EmailPasswordResetLimitError(BaseHTTPException): code = 429 -class EmailRegisterLimitError(BaseHTTPException): - error_code = "email_register_limit" - description = "Too many failed email register attempts. Please try again in 24 hours." - code = 429 - - class EmailChangeLimitError(BaseHTTPException): error_code = "email_change_limit" description = "Too many failed email change attempts. Please try again in 24 hours." diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index d7558e0f67..ede0696854 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -6,6 +6,7 @@ from flask_restx import Resource, reqparse from sqlalchemy import select from sqlalchemy.orm import Session +from constants.languages import languages from controllers.console import api from controllers.console.auth.error import ( EmailCodeError, @@ -14,7 +15,7 @@ from controllers.console.auth.error import ( InvalidTokenError, PasswordMismatchError, ) -from controllers.console.error import AccountNotFound, EmailSendIpLimitError +from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError from controllers.console.wraps import email_password_login_enabled, setup_required from events.tenant_event import tenant_was_created from extensions.ext_database import db @@ -22,6 +23,8 @@ from libs.helper import email, extract_remote_ip from libs.password import hash_password, valid_password from models.account import Account from services.account_service import AccountService, TenantService +from services.errors.account import AccountRegisterError +from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService @@ -45,13 +48,15 @@ class ForgotPasswordSendEmailApi(Resource): with Session(db.engine) as session: account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() - - token = AccountService.send_reset_password_email( - account=account, - email=args["email"], - language=language, - is_allow_register=FeatureService.get_system_features().is_allow_register, - ) + token = None + if account is None: + if FeatureService.get_system_features().is_allow_register: + token = AccountService.send_reset_password_email(email=args["email"], language=language) + return {"result": "fail", "data": token, "code": "account_not_found"} + else: + raise AccountNotFound() + else: + token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language) return {"result": "success", "data": token} @@ -132,7 +137,7 @@ class ForgotPasswordResetApi(Resource): if account: self._update_existing_account(account, password_hashed, salt, session) else: - raise AccountNotFound() + self._create_new_account(email, args["password_confirm"]) return {"result": "success"} @@ -152,6 +157,22 @@ class ForgotPasswordResetApi(Resource): account.current_tenant = tenant tenant_was_created.send(tenant) + def _create_new_account(self, email, password): + # Create new account if allowed + try: + AccountService.create_account_and_tenant( + email=email, + name=email, + password=password, + interface_language=languages[0], + ) + except WorkSpaceNotAllowedCreateError: + pass + except WorkspacesLimitExceededError: + pass + except AccountRegisterError: + raise AccountInFreezeError() + api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password") api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity") diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 3b35ab3c23..b11bc0c6ac 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -26,6 +26,7 @@ from controllers.console.error import ( from controllers.console.wraps import email_password_login_enabled, setup_required from events.tenant_event import tenant_was_created from libs.helper import email, extract_remote_ip +from libs.password import valid_password from models.account import Account from services.account_service import AccountService, RegisterService, TenantService from services.billing_service import BillingService @@ -43,9 +44,10 @@ class LoginApi(Resource): """Authenticate user and login.""" parser = reqparse.RequestParser() parser.add_argument("email", type=email, required=True, location="json") - parser.add_argument("password", type=str, required=True, location="json") + parser.add_argument("password", type=valid_password, required=True, location="json") parser.add_argument("remember_me", type=bool, required=False, default=False, location="json") parser.add_argument("invite_token", type=str, required=False, default=None, location="json") + parser.add_argument("language", type=str, required=False, default="en-US", location="json") args = parser.parse_args() if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): @@ -59,6 +61,11 @@ class LoginApi(Resource): if invitation: invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation) + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + try: if invitation: data = invitation.get("data", {}) @@ -73,6 +80,12 @@ class LoginApi(Resource): except services.errors.account.AccountPasswordError: AccountService.add_login_error_rate_limit(args["email"]) raise AuthenticationFailedError() + except services.errors.account.AccountNotFoundError: + if FeatureService.get_system_features().is_allow_register: + token = AccountService.send_reset_password_email(email=args["email"], language=language) + return {"result": "fail", "data": token, "code": "account_not_found"} + else: + raise AccountNotFound() # SELF_HOSTED only have one workspace tenants = TenantService.get_join_tenants(account) if len(tenants) == 0: @@ -120,12 +133,13 @@ class ResetPasswordSendEmailApi(Resource): except AccountRegisterError: raise AccountInFreezeError() - token = AccountService.send_reset_password_email( - email=args["email"], - account=account, - language=language, - is_allow_register=FeatureService.get_system_features().is_allow_register, - ) + if account is None: + if FeatureService.get_system_features().is_allow_register: + token = AccountService.send_reset_password_email(email=args["email"], language=language) + else: + raise AccountNotFound() + else: + token = AccountService.send_reset_password_email(account=account, language=language) return {"result": "success", "data": token} diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 092071481e..e375fe285b 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -242,19 +242,6 @@ def email_password_login_enabled(view: Callable[P, R]): return decorated -def email_register_enabled(view): - @wraps(view) - def decorated(*args, **kwargs): - features = FeatureService.get_system_features() - if features.is_allow_register: - return view(*args, **kwargs) - - # otherwise, return 403 - abort(403) - - return decorated - - def enable_change_email(view: Callable[P, R]): @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): diff --git a/api/libs/email_i18n.py b/api/libs/email_i18n.py index 9dde87d800..3c039dff53 100644 --- a/api/libs/email_i18n.py +++ b/api/libs/email_i18n.py @@ -21,7 +21,6 @@ class EmailType(Enum): """Enumeration of supported email types.""" RESET_PASSWORD = "reset_password" - RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST = "reset_password_when_account_not_exist" INVITE_MEMBER = "invite_member" EMAIL_CODE_LOGIN = "email_code_login" CHANGE_EMAIL_OLD = "change_email_old" @@ -35,9 +34,6 @@ class EmailType(Enum): ENTERPRISE_CUSTOM = "enterprise_custom" QUEUE_MONITOR_ALERT = "queue_monitor_alert" DOCUMENT_CLEAN_NOTIFY = "document_clean_notify" - EMAIL_REGISTER = "email_register" - EMAIL_REGISTER_WHEN_ACCOUNT_EXIST = "email_register_when_account_exist" - RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER = "reset_password_when_account_not_exist_no_register" class EmailLanguage(Enum): @@ -445,54 +441,6 @@ def create_default_email_config() -> EmailI18nConfig: branded_template_path="clean_document_job_mail_template_zh-CN.html", ), }, - EmailType.EMAIL_REGISTER: { - EmailLanguage.EN_US: EmailTemplate( - subject="Register Your {application_title} Account", - template_path="register_email_template_en-US.html", - branded_template_path="without-brand/register_email_template_en-US.html", - ), - EmailLanguage.ZH_HANS: EmailTemplate( - subject="注册您的 {application_title} 账户", - template_path="register_email_template_zh-CN.html", - branded_template_path="without-brand/register_email_template_zh-CN.html", - ), - }, - EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST: { - EmailLanguage.EN_US: EmailTemplate( - subject="Register Your {application_title} Account", - template_path="register_email_when_account_exist_template_en-US.html", - branded_template_path="without-brand/register_email_when_account_exist_template_en-US.html", - ), - EmailLanguage.ZH_HANS: EmailTemplate( - subject="注册您的 {application_title} 账户", - template_path="register_email_when_account_exist_template_zh-CN.html", - branded_template_path="without-brand/register_email_when_account_exist_template_zh-CN.html", - ), - }, - EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST: { - EmailLanguage.EN_US: EmailTemplate( - subject="Reset Your {application_title} Password", - template_path="reset_password_mail_when_account_not_exist_template_en-US.html", - branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_en-US.html", - ), - EmailLanguage.ZH_HANS: EmailTemplate( - subject="重置您的 {application_title} 密码", - template_path="reset_password_mail_when_account_not_exist_template_zh-CN.html", - branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html", - ), - }, - EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER: { - EmailLanguage.EN_US: EmailTemplate( - subject="Reset Your {application_title} Password", - template_path="reset_password_mail_when_account_not_exist_no_register_template_en-US.html", - branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html", - ), - EmailLanguage.ZH_HANS: EmailTemplate( - subject="重置您的 {application_title} 密码", - template_path="reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", - branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", - ), - }, } return EmailI18nConfig(templates=templates) diff --git a/api/services/account_service.py b/api/services/account_service.py index 8438423f2e..a76792f88e 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -37,6 +37,7 @@ from services.billing_service import BillingService from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, + AccountNotFoundError, AccountNotLinkTenantError, AccountPasswordError, AccountRegisterError, @@ -64,11 +65,7 @@ from tasks.mail_owner_transfer_task import ( send_old_owner_transfer_notify_email_task, send_owner_transfer_confirm_task, ) -from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist -from tasks.mail_reset_password_task import ( - send_reset_password_mail_task, - send_reset_password_mail_task_when_account_not_exist, -) +from tasks.mail_reset_password_task import send_reset_password_mail_task logger = logging.getLogger(__name__) @@ -85,7 +82,6 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS) class AccountService: reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) - email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1 ) @@ -99,7 +95,6 @@ class AccountService: FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 - EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: @@ -176,7 +171,7 @@ class AccountService: account = db.session.query(Account).filter_by(email=email).first() if not account: - raise AccountPasswordError("Invalid email or password.") + raise AccountNotFoundError() if account.status == AccountStatus.BANNED.value: raise AccountLoginError("Account is banned.") @@ -438,7 +433,6 @@ class AccountService: account: Optional[Account] = None, email: Optional[str] = None, language: str = "en-US", - is_allow_register: bool = False, ): account_email = account.email if account else email if account_email is None: @@ -451,54 +445,14 @@ class AccountService: code, token = cls.generate_reset_password_token(account_email, account) - if account: - send_reset_password_mail_task.delay( - language=language, - to=account_email, - code=code, - ) - else: - send_reset_password_mail_task_when_account_not_exist.delay( - language=language, - to=account_email, - is_allow_register=is_allow_register, - ) + send_reset_password_mail_task.delay( + language=language, + to=account_email, + code=code, + ) cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token - @classmethod - def send_email_register_email( - cls, - account: Optional[Account] = None, - email: Optional[str] = None, - language: str = "en-US", - ): - account_email = account.email if account else email - if account_email is None: - raise ValueError("Email must be provided.") - - if cls.email_register_rate_limiter.is_rate_limited(account_email): - from controllers.console.auth.error import EmailRegisterRateLimitExceededError - - raise EmailRegisterRateLimitExceededError() - - code, token = cls.generate_email_register_token(account_email) - - if account: - send_email_register_mail_task_when_account_exist.delay( - language=language, - to=account_email, - ) - - else: - send_email_register_mail_task.delay( - language=language, - to=account_email, - code=code, - ) - cls.email_register_rate_limiter.increment_rate_limit(account_email) - return token - @classmethod def send_change_email_email( cls, @@ -631,19 +585,6 @@ class AccountService: ) return code, token - @classmethod - def generate_email_register_token( - cls, - email: str, - code: Optional[str] = None, - additional_data: dict[str, Any] = {}, - ): - if not code: - code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) - additional_data["code"] = code - token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data) - return code, token - @classmethod def generate_change_email_token( cls, @@ -682,10 +623,6 @@ class AccountService: def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") - @classmethod - def revoke_email_register_token(cls, token: str): - TokenManager.revoke_token(token, "email_register") - @classmethod def revoke_change_email_token(cls, token: str): TokenManager.revoke_token(token, "change_email") @@ -698,10 +635,6 @@ class AccountService: def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") - @classmethod - def get_email_register_data(cls, token: str) -> Optional[dict[str, Any]]: - return TokenManager.get_token_data(token, "email_register") - @classmethod def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "change_email") @@ -809,16 +742,6 @@ class AccountService: count = int(count) + 1 redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) - @staticmethod - @redis_fallback(default_return=None) - def add_email_register_error_rate_limit(email: str) -> None: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - count = 0 - count = int(count) + 1 - redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count) - @staticmethod @redis_fallback(default_return=False) def is_forgot_password_error_rate_limit(email: str) -> bool: @@ -838,24 +761,6 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) - @staticmethod - @redis_fallback(default_return=False) - def is_email_register_error_rate_limit(email: str) -> bool: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - return False - count = int(count) - if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS: - return True - return False - - @staticmethod - @redis_fallback(default_return=None) - def reset_email_register_error_rate_limit(email: str): - key = f"email_register_error_rate_limit:{email}" - redis_client.delete(key) - @staticmethod @redis_fallback(default_return=None) def add_change_email_error_rate_limit(email: str): diff --git a/api/tasks/mail_register_task.py b/api/tasks/mail_register_task.py deleted file mode 100644 index acf2852649..0000000000 --- a/api/tasks/mail_register_task.py +++ /dev/null @@ -1,86 +0,0 @@ -import logging -import time - -import click -from celery import shared_task - -from configs import dify_config -from extensions.ext_mail import mail -from libs.email_i18n import EmailType, get_email_i18n_service - -logger = logging.getLogger(__name__) - - -@shared_task(queue="mail") -def send_email_register_mail_task(language: str, to: str, code: str) -> None: - """ - Send email register email with internationalization support. - - Args: - language: Language code for email localization - to: Recipient email address - code: Email register code - """ - if not mail.is_inited(): - return - - logger.info(click.style(f"Start email register mail to {to}", fg="green")) - start_at = time.perf_counter() - - try: - email_service = get_email_i18n_service() - email_service.send_email( - email_type=EmailType.EMAIL_REGISTER, - language_code=language, - to=to, - template_context={ - "to": to, - "code": code, - }, - ) - - end_at = time.perf_counter() - logger.info( - click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") - ) - except Exception: - logger.exception("Send email register mail to %s failed", to) - - -@shared_task(queue="mail") -def send_email_register_mail_task_when_account_exist(language: str, to: str) -> None: - """ - Send email register email with internationalization support when account exist. - - Args: - language: Language code for email localization - to: Recipient email address - """ - if not mail.is_inited(): - return - - logger.info(click.style(f"Start email register mail to {to}", fg="green")) - start_at = time.perf_counter() - - try: - login_url = f"{dify_config.CONSOLE_WEB_URL}/signin" - reset_password_url = f"{dify_config.CONSOLE_WEB_URL}/reset-password" - - email_service = get_email_i18n_service() - email_service.send_email( - email_type=EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST, - language_code=language, - to=to, - template_context={ - "to": to, - "login_url": login_url, - "reset_password_url": reset_password_url, - }, - ) - - end_at = time.perf_counter() - logger.info( - click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") - ) - except Exception: - logger.exception("Send email register mail to %s failed", to) diff --git a/api/tasks/mail_reset_password_task.py b/api/tasks/mail_reset_password_task.py index 1739562588..545db84fde 100644 --- a/api/tasks/mail_reset_password_task.py +++ b/api/tasks/mail_reset_password_task.py @@ -4,7 +4,6 @@ import time import click from celery import shared_task -from configs import dify_config from extensions.ext_mail import mail from libs.email_i18n import EmailType, get_email_i18n_service @@ -45,47 +44,3 @@ def send_reset_password_mail_task(language: str, to: str, code: str): ) except Exception: logger.exception("Send password reset mail to %s failed", to) - - -@shared_task(queue="mail") -def send_reset_password_mail_task_when_account_not_exist(language: str, to: str, is_allow_register: bool) -> None: - """ - Send reset password email with internationalization support when account not exist. - - Args: - language: Language code for email localization - to: Recipient email address - """ - if not mail.is_inited(): - return - - logger.info(click.style(f"Start password reset mail to {to}", fg="green")) - start_at = time.perf_counter() - - try: - if is_allow_register: - sign_up_url = f"{dify_config.CONSOLE_WEB_URL}/signup" - email_service = get_email_i18n_service() - email_service.send_email( - email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST, - language_code=language, - to=to, - template_context={ - "to": to, - "sign_up_url": sign_up_url, - }, - ) - else: - email_service = get_email_i18n_service() - email_service.send_email( - email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER, - language_code=language, - to=to, - ) - - end_at = time.perf_counter() - logger.info( - click.style(f"Send password reset mail to {to} succeeded: latency: {end_at - start_at}", fg="green") - ) - except Exception: - logger.exception("Send password reset mail to %s failed", to) diff --git a/api/templates/register_email_template_en-US.html b/api/templates/register_email_template_en-US.html deleted file mode 100644 index e0fec59100..0000000000 --- a/api/templates/register_email_template_en-US.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

Dify Sign-up Code

-

Your sign-up code for Dify - - Copy and paste this code, this code will only be valid for the next 5 minutes.

-
- {{code}} -
-

If you didn't request this code, don't worry. You can safely ignore this email.

-
- - - \ No newline at end of file diff --git a/api/templates/register_email_template_zh-CN.html b/api/templates/register_email_template_zh-CN.html deleted file mode 100644 index 3b507290f0..0000000000 --- a/api/templates/register_email_template_zh-CN.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

Dify 注册验证码

-

您的 Dify 注册验证码 - - 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。

-
- {{code}} -
-

如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。

-
- - - \ No newline at end of file diff --git a/api/templates/register_email_when_account_exist_template_en-US.html b/api/templates/register_email_when_account_exist_template_en-US.html deleted file mode 100644 index 967f97a1b8..0000000000 --- a/api/templates/register_email_when_account_exist_template_en-US.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

It looks like you’re signing up with an existing account

-

Hi, - We noticed you tried to sign up, but this email is already registered with an existing account. - - Please log in here:

-

- Log In -

-

- If you forgot your password, you can reset it here:

-

- Reset Password -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/register_email_when_account_exist_template_zh-CN.html b/api/templates/register_email_when_account_exist_template_zh-CN.html deleted file mode 100644 index 7d63ca06e8..0000000000 --- a/api/templates/register_email_when_account_exist_template_zh-CN.html +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

您似乎正在使用现有账户注册

-

Hi, - 我们注意到您尝试注册,但此电子邮件已与现有账户注册。 - - 请在此登录:

-

- 登录 -

-

- 如果您忘记了密码,可以在此重置:

-

- 重置密码 -

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html deleted file mode 100644 index c849057519..0000000000 --- a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_en-US.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

It looks like you’re resetting a password with an unregistered email

-

Hi, - We noticed you tried to reset your password, but this email is not associated with any account. -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html b/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html deleted file mode 100644 index 51ed79cfbb..0000000000 --- a/api/templates/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

看起来您正在使用未注册的电子邮件重置密码

-

Hi, - 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html b/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html deleted file mode 100644 index 4ad82a2ccd..0000000000 --- a/api/templates/reset_password_mail_when_account_not_exist_template_en-US.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

It looks like you’re resetting a password with an unregistered email

-

Hi, - We noticed you tried to reset your password, but this email is not associated with any account. - - Please sign up here:

-

- [Sign Up] -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html b/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html deleted file mode 100644 index 284d700485..0000000000 --- a/api/templates/reset_password_mail_when_account_not_exist_template_zh-CN.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - -
-
- - Dify Logo -
-

看起来您正在使用未注册的电子邮件重置密码

-

Hi, - 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 - - 请在此注册:

-

- [注册] -

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/register_email_template_en-US.html b/api/templates/without-brand/register_email_template_en-US.html deleted file mode 100644 index 65e179ef18..0000000000 --- a/api/templates/without-brand/register_email_template_en-US.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - -
-

{{application_title}} Sign-up Code

-

Your sign-up code for Dify - - Copy and paste this code, this code will only be valid for the next 5 minutes.

-
- {{code}} -
-

If you didn't request this code, don't worry. You can safely ignore this email.

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/register_email_template_zh-CN.html b/api/templates/without-brand/register_email_template_zh-CN.html deleted file mode 100644 index 26df4760aa..0000000000 --- a/api/templates/without-brand/register_email_template_zh-CN.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - -
-

{{application_title}} 注册验证码

-

您的 {{application_title}} 注册验证码 - - 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。

-
- {{code}} -
-

如果您没有请求此验证码,请不要担心。您可以安全地忽略此电子邮件。

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html deleted file mode 100644 index 063d0de34c..0000000000 --- a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - -
-

It looks like you’re signing up with an existing account

-

Hi, - We noticed you tried to sign up, but this email is already registered with an existing account. - - Please log in here:

-

- Log In -

-

- If you forgot your password, you can reset it here:

-

- Reset Password -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html deleted file mode 100644 index 3edbd25e87..0000000000 --- a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - -
-

您似乎正在使用现有账户注册

-

Hi, - 我们注意到您尝试注册,但此电子邮件已与现有账户注册。 - - 请在此登录:

-

- 登录 -

-

- 如果您忘记了密码,可以在此重置:

-

- 重置密码 -

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html deleted file mode 100644 index 5e6d2f1671..0000000000 --- a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - -
-

It looks like you’re resetting a password with an unregistered email

-

Hi, - We noticed you tried to reset your password, but this email is not associated with any account. -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html deleted file mode 100644 index fd53becef6..0000000000 --- a/api/templates/without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - -
-

看起来您正在使用未注册的电子邮件重置密码

-

Hi, - 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 -

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html deleted file mode 100644 index c67400593f..0000000000 --- a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_en-US.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - -
-

It looks like you’re resetting a password with an unregistered email

-

Hi, - We noticed you tried to reset your password, but this email is not associated with any account. - - Please sign up here:

-

- [Sign Up] -

-

If you didn’t request this action, you can safely ignore this email. - Need help? Feel free to contact us at support@dify.ai.

-
- - - \ No newline at end of file diff --git a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html b/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html deleted file mode 100644 index bfd0272831..0000000000 --- a/api/templates/without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - -
-

看起来您正在使用未注册的电子邮件重置密码

-

Hi, - 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 - - 请在此注册:

-

- [注册] -

-

如果您没有请求此操作,您可以安全地忽略此电子邮件。 - 需要帮助?随时联系我们 at support@dify.ai。

-
- - - \ No newline at end of file diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 92df93fb13..2e98dec964 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -203,7 +203,6 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 -EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index fef353b0e2..415e65ce51 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -13,6 +13,7 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, + AccountNotFoundError, AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, @@ -138,7 +139,7 @@ class TestAccountService: fake = Faker() email = fake.email() password = fake.password(length=12) - with pytest.raises(AccountPasswordError): + with pytest.raises(AccountNotFoundError): AccountService.authenticate(email, password) def test_authenticate_banned_account(self, db_session_with_containers, mock_external_service_dependencies): diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index b6697ac5d4..aefb4bf8b0 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -9,6 +9,7 @@ from flask_restx import Api import services.errors.account from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi +from controllers.console.error import AccountNotFound class TestAuthenticationSecurity: @@ -26,33 +27,31 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.FeatureService.get_system_features") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") - @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") + @patch("controllers.console.auth.login.AccountService.send_reset_password_email") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") def test_login_invalid_email_with_registration_allowed( - self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db + self, mock_get_invitation, mock_send_email, mock_authenticate, mock_is_rate_limit, mock_features, mock_db ): - """Test that invalid email raises AuthenticationFailedError when account not found.""" + """Test that invalid email sends reset password email when registration is allowed.""" # Arrange mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None - mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") + mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = True + mock_send_email.return_value = "token123" # Act with self.app.test_request_context( "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} ): login_api = LoginApi() + result = login_api.post() - # Assert - with pytest.raises(AuthenticationFailedError) as exc_info: - login_api.post() - - assert exc_info.value.error_code == "authentication_failed" - assert exc_info.value.description == "Invalid email or password." - mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") + # Assert + assert result == {"result": "fail", "data": "token123", "code": "account_not_found"} + mock_send_email.assert_called_once_with(email="nonexistent@example.com", language="en-US") @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @@ -88,17 +87,16 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.FeatureService.get_system_features") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") - @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") def test_login_invalid_email_with_registration_disabled( - self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db + self, mock_get_invitation, mock_authenticate, mock_is_rate_limit, mock_features, mock_db ): - """Test that invalid email raises AuthenticationFailedError when account not found.""" + """Test that invalid email raises AccountNotFound when registration is disabled.""" # Arrange mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None - mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") + mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = False @@ -109,12 +107,10 @@ class TestAuthenticationSecurity: login_api = LoginApi() # Assert - with pytest.raises(AuthenticationFailedError) as exc_info: + with pytest.raises(AccountNotFound) as exc_info: login_api.post() - assert exc_info.value.error_code == "authentication_failed" - assert exc_info.value.description == "Invalid email or password." - mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") + assert exc_info.value.error_code == "account_not_found" @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.FeatureService.get_system_features") diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index ed70a7b0de..442839e44e 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -10,6 +10,7 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, + AccountNotFoundError, AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, @@ -194,7 +195,7 @@ class TestAccountService: # Execute test and verify exception self._assert_exception_raised( - AccountPasswordError, AccountService.authenticate, "notfound@example.com", "password" + AccountNotFoundError, AccountService.authenticate, "notfound@example.com", "password" ) def test_authenticate_account_banned(self, mock_db_dependencies): diff --git a/docker/.env.example b/docker/.env.example index 92347a6e76..9a0a5a9622 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -843,7 +843,6 @@ INVITE_EXPIRY_HOURS=72 # Reset password token valid time (minutes), RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 -EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 193157b54f..3f19dc7f63 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -372,7 +372,6 @@ x-shared-env: &shared-api-worker-env INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} - EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5} CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} From ec0800eb1aa145b91d492f3068d6efeaab179257 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 8 Sep 2025 19:55:25 +0800 Subject: [PATCH 10/10] refactor: update pyrightconfig.json to use ignore field for better type checking configuration (#25373) --- api/pyrightconfig.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json index 059b8bba4f..a3a5f2044e 100644 --- a/api/pyrightconfig.json +++ b/api/pyrightconfig.json @@ -1,11 +1,7 @@ { - "include": [ - "." - ], - "exclude": [ - "tests/", - "migrations/", - ".venv/", + "include": ["models", "configs"], + "exclude": [".venv", "tests/", "migrations/"], + "ignore": [ "core/", "controllers/", "tasks/", @@ -25,4 +21,4 @@ "typeCheckingMode": "strict", "pythonVersion": "3.11", "pythonPlatform": "All" -} \ No newline at end of file +}