diff --git a/.dockerignore b/.dockerignore index 14e1573f..b6c2abb3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,11 @@ coverage.xml .pytest_cache .hypothesis -# 排除敏感配置 +# 排除敏感配置(含 backend/.env,避免构建时带入本地配置) .env .env.local -.env.*.local \ No newline at end of file +.env.*.local +backend/.env +**/.env +**/.env.local +**/.env.*.local \ No newline at end of file diff --git a/backend/apps/system/api/assistant.py b/backend/apps/system/api/assistant.py index 5db3f403..2575900f 100644 --- a/backend/apps/system/api/assistant.py +++ b/backend/apps/system/api/assistant.py @@ -2,9 +2,11 @@ import os from datetime import timedelta from typing import List, Optional +from urllib.parse import urlparse -from fastapi import APIRouter, Form, HTTPException, Path, Query, Request, Response, UploadFile +from fastapi import APIRouter, Body, Form, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field from sqlbot_xpack.file_utils import SQLBotFileUtils from sqlmodel import select @@ -21,7 +23,8 @@ from common.core.deps import CurrentAssistant, SessionDep, Trans, CurrentUser from common.core.security import create_access_token from common.core.sqlbot_cache import clear_cache -from common.utils.utils import get_origin_from_referer, origin_match_domain +from common.utils.utils import get_domain_list, get_origin_from_referer, origin_match_domain, validate_domain_settings +from common.utils.http_utils import verify_url router = APIRouter(tags=["system_assistant"], prefix="/system/assistant") from common.audit.models.log_model import OperationType, OperationModules @@ -67,6 +70,42 @@ async def getApp(request: Request, response: Response, session: SessionDep, tran return db_model +class ValidateDomainBody(BaseModel): + domain: Optional[str] = Field(default=None, description="跨域设置字符串,多个用逗号或分号分隔") + + +@router.post("/validate-domain", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def validate_domain(body: ValidateDomainBody = Body(...)): + """校验跨域设置格式是否正确,并对每个域名做可访问性检查(请求该地址,无法连接或超时则视为不可用)。""" + domain = body.domain or "" + valid, msg = validate_domain_settings(domain) + if not valid: + if msg == "empty": + return {"valid": False, "message": "domain_empty"} + return {"valid": False, "message": "domain_invalid", "invalid_value": msg} + # 格式正确,再检查每个域名是否可访问(localhost/127.0.0.1 仅校验格式,不请求) + origins = get_domain_list(domain) + unreachable_list: List[dict] = [] + for origin in origins: + try: + host = urlparse(origin).hostname or "" + if host.lower() in ("localhost", "127.0.0.1"): + continue + except Exception: + pass + reachable, reason = verify_url(origin, timeout=5) + if not reachable: + unreachable_list.append({"url": origin, "reason": reason}) + if unreachable_list: + return { + "valid": False, + "message": "domain_unreachable", + "unreachable_list": unreachable_list, + } + return {"valid": True, "message": ""} + + @router.get("/validator", response_model=AssistantValidator, include_in_schema=False) async def validator(session: SessionDep, id: int, virtual: Optional[int] = Query(None)): if not id: diff --git a/backend/common/utils/utils.py b/backend/common/utils/utils.py index 3c3bdd0d..79fee5ec 100644 --- a/backend/common/utils/utils.py +++ b/backend/common/utils/utils.py @@ -284,7 +284,35 @@ def get_domain_list(domain: str) -> list[str]: if d_clean: domains.append(d_clean) return domains - + + +# 跨域设置单条格式:http(s)://host[:port],不能以 / 结尾,与前端校验一致 +_DOMAIN_ORIGIN_PATTERN = re.compile(r'^https?://[^\s/?#]+(:\d+)?$', re.IGNORECASE) + + +def validate_domain_origin_format(origin: str) -> bool: + """校验单条跨域 origin 格式是否合法(与前端规则一致)。""" + if not origin or not isinstance(origin, str): + return False + s = origin.strip().rstrip('/') + return bool(s and _DOMAIN_ORIGIN_PATTERN.match(s)) + + +def validate_domain_settings(domain: str) -> tuple[bool, str]: + """ + 校验小助手跨域设置整串是否合法。 + 返回 (是否全部合法, 错误信息;合法时为空)。 + """ + if not domain or not domain.strip(): + return False, "empty" + for part in re.split(r'[,;]', domain): + s = part.strip().rstrip('/') + if not s: + continue + if not _DOMAIN_ORIGIN_PATTERN.match(s): + return False, s + return True, "" + def equals_ignore_case(str1: str, *args: str) -> bool: if str1 is None: diff --git a/build/.dockerignore b/build/.dockerignore index 14e1573f..b6c2abb3 100644 --- a/build/.dockerignore +++ b/build/.dockerignore @@ -19,7 +19,11 @@ coverage.xml .pytest_cache .hypothesis -# 排除敏感配置 +# 排除敏感配置(含 backend/.env,避免构建时带入本地配置) .env .env.local -.env.*.local \ No newline at end of file +.env.*.local +backend/.env +**/.env +**/.env.local +**/.env.*.local \ No newline at end of file diff --git a/frontend/src/api/embedded.ts b/frontend/src/api/embedded.ts index 0733088a..448a84fe 100644 --- a/frontend/src/api/embedded.ts +++ b/frontend/src/api/embedded.ts @@ -8,6 +8,15 @@ export const saveAssistant = (data: any) => request.post('/system/assistant', da export const getOne = (id: any) => request.get(`/system/assistant/${id}`) export const delOne = (id: any) => request.delete(`/system/assistant/${id}`) export const dsApi = (id: any) => request.get(`/datasource/ws/${id}`) +export interface ValidateDomainRes { + valid: boolean + message?: string + invalid_value?: string + unreachable_list?: Array<{ url: string; reason: string }> +} + +export const validateAssistantDomain = (domain: string) => + request.post('/system/assistant/validate-domain', { domain }) export const embeddedApi = { getList: (pageNum: any, pageSize: any, params: any) => diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 2fe8fb3f..489e6f83 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -758,7 +758,8 @@ "application_name": "Application name", "application_description": "Application description", "cross_domain_settings": "Cross-domain settings", - "third_party_address": "Please enter the embedded third party address,multiple items separated by semicolons", + "cross_domain_remark": "Enter the allowed page origins (Origin) where this assistant can be embedded. Only pages from these addresses can load the embed code successfully. Format: start with http:// or https://, no trailing slash (/); separate multiple entries with comma or semicolon. e.g. https://localhost:8080 or https://example.com,https://app.example.com", + "third_party_address": "e.g. https://localhost:8080 or https://example.com; separate multiple with comma or semicolon", "set_to_private": "Set as private", "set_to_public": "Set as public", "public": "Public", @@ -766,8 +767,15 @@ "creating_advanced_applications": "Creating Advanced Applications", "configure_interface": "Configure interface", "interface_url": "Interface URL", + "interface_url_remark": "Data sources for advanced applications are provided by your system via an API. Enter the API URL here: use a relative path (e.g. /api/datasource/list) to be combined with the domain in cross-domain settings, or a full URL (starting with http:// or https://). At runtime, the system will call this endpoint to get the list of available data sources.", + "interface_credentials_remark": "When calling the data source API above, these credentials are sent as headers, cookies, or query parameters for authentication or user context. Configure source (e.g. localStorage/cookie), target (header/cookie/param), and key/value as required by your API.", "format_is_incorrect": "format is incorrect{msg}", "domain_format_incorrect": ", start with http:// or https://, no trailing slash (/), multiple domains separated by semicolons", + "domain_test": "Test", + "domain_test_success": "Cross-domain settings are valid and all origins are reachable. After saving, the assistant can be used when embedded on these origins.", + "domain_test_empty": "Please enter cross-domain settings first", + "domain_test_fail": "Invalid format: {msg} does not meet requirements (must start with http/https, no trailing /)", + "domain_test_unreachable": "The following address(es) are unreachable; check accessibility or typos: {detail}", "interface_url_incorrect": ",enter a relative path starting with /", "aes_enable": "Enable AES encryption", "aes_enable_tips": "The fields (host, user, password, dataBase, schema) are all encrypted using the AES-CBC-PKCS5Padding encryption method", diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index 0878a571..ad541b29 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -734,15 +734,23 @@ "application_name": "애플리케이션 이름", "application_description": "애플리케이션 설명", "cross_domain_settings": "교차 도메인 설정", - "third_party_address": "임베디드할 제3자 주소를 입력하십시오, 여러 항목을 세미콜론으로 구분", + "cross_domain_remark": "이 어시스턴트를 임베드할 수 있는 페이지 출처(Origin)를 입력하세요. 이 주소에서만 임베드 코드가 정상 작동합니다. 형식: http:// 또는 https://로 시작, 끝에 / 없음; 여러 개는 쉼표 또는 세미콜론으로 구분. 예: https://localhost:8080 또는 https://example.com,https://app.example.com", + "third_party_address": "예: https://localhost:8080 또는 https://example.com; 여러 개는 쉼표 또는 세미콜론으로 구분", "set_to_private": "비공개로 설정", "set_to_public": "공개로 설정", "public": "공개", "private": "비공개", "configure_interface": "인터페이스 설정", "interface_url": "인터페이스 URL", + "interface_url_remark": "고급 애플리케이션의 데이터 소스는 제3자 시스템이 API를 통해 동적으로 제공합니다. 여기에 해당 API 주소를 입력하세요: 상대 경로(예: /api/datasource/list)를 입력하면 교차 도메인 설정의 도메인과 결합되고, 전체 URL(http:// 또는 https://로 시작)을 입력하면 그대로 사용됩니다. 런타임에 시스템이 이 엔드포인트를 호출하여 사용 가능한 데이터 소스 목록을 가져옵니다.", + "interface_credentials_remark": "위 데이터 소스 API를 호출할 때 인증 또는 사용자 컨텍스트를 위해 여기에 구성한 자격 증명이 헤더, 쿠키 또는 쿼리 매개변수로 전송됩니다. API 요구사항에 따라 소스(localStorage/cookie 등), 대상(header/cookie/param) 및 키/값을 구성하세요.", "format_is_incorrect": "형식이 올바르지 않습니다{msg}", "domain_format_incorrect": ", http:// 또는 https://로 시작해야 하며, 슬래시(/)로 끝날 수 없습니다. 여러 도메인은 세미콜론으로 구분합니다", + "domain_test": "테스트", + "domain_test_success": "교차 도메인 설정 형식이 올바르며 모든 주소에 접근 가능합니다. 저장 후 해당 도메인에서 임베드된 어시스턴트를 사용할 수 있습니다.", + "domain_test_empty": "먼저 교차 도메인 설정을 입력하세요", + "domain_test_fail": "형식 오류: {msg} 요구사항에 맞지 않습니다(http/https로 시작, 끝에 / 없음)", + "domain_test_unreachable": "다음 주소에 접근할 수 없습니다. 연결 상태나 오타를 확인하세요: {detail}", "interface_url_incorrect": ", 상대 경로를 입력해주세요. /로 시작합니다", "aes_enable": "AES 암호화 활성화", "aes_enable_tips": "암호화 필드 (host, user, password, dataBase, schema)는 모두 AES-CBC-PKCS5Padding 암호화 방식을 사용합니다", diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index 7f226b27..463ed9ac 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -758,15 +758,23 @@ "application_name": "应用名称", "application_description": "应用描述", "cross_domain_settings": "跨域设置", - "third_party_address": "请输入嵌入的第三方地址,多个以分号分割", + "cross_domain_remark": "填写允许嵌入该小助手的网页来源(Origin),只有这些地址加载的嵌入代码才能正常访问。格式:以 http:// 或 https:// 开头,不要以 / 结尾;多个用英文逗号或分号分隔。例如:https://localhost:8080 或 https://example.com,https://app.example.com", + "third_party_address": "例如:https://localhost:8080 或 https://example.com;多个用逗号或分号分隔", "set_to_private": "设为私有", "set_to_public": "设为公共", "public": "公共", "private": "私有", "configure_interface": "配置接口", "interface_url": "接口 URL", + "interface_url_remark": "高级应用的数据源由第三方系统通过接口动态返回。此处填写该接口的地址:若填相对路径(如 /api/datasource/list),将与跨域设置中的域名拼接;若填完整地址(以 http:// 或 https:// 开头)则直接使用。小助手运行时,系统会请求该接口获取当前可用的数据源列表。", + "interface_credentials_remark": "调用上述数据源接口时,系统会将此处配置的凭证以 Header、Cookie 或 URL 参数方式一并发送,用于第三方鉴权或区分用户。请按第三方接口要求配置来源(如 localStorage/cookie)及目标位置(header/cookie/param)和键名、键值。", "format_is_incorrect": "格式不对{msg}", "domain_format_incorrect": ",http或https开头,不能以 / 结尾,多个域名以分号(半角)分隔", + "domain_test": "测试", + "domain_test_success": "跨域设置格式正确且所有地址均可访问,保存后在这些域名下嵌入的小助手可正常使用", + "domain_test_empty": "请先填写跨域设置", + "domain_test_fail": "格式有误:{msg} 不符合要求(需 http/https 开头、不以 / 结尾)", + "domain_test_unreachable": "以下地址无法访问,请检查是否可访问或存在拼写错误:{detail}", "interface_url_incorrect": ",请填写相对路径,以/开头", "aes_enable": "开启 AES 加密", "aes_enable_tips": "加密字段 (host, user, password, dataBase, schema) 均采用 AES-CBC-PKCS5Padding 加密方式", diff --git a/frontend/src/views/system/embedded/iframe.vue b/frontend/src/views/system/embedded/iframe.vue index 76978f0a..101dd15a 100644 --- a/frontend/src/views/system/embedded/iframe.vue +++ b/frontend/src/views/system/embedded/iframe.vue @@ -16,7 +16,7 @@ import SetUi from './SetUi.vue' import Card from './Card.vue' // import { workspaceList } from '@/api/workspace' import DsCard from './DsCard.vue' -import { getList, updateAssistant, saveAssistant, delOne, dsApi } from '@/api/embedded' +import { getList, updateAssistant, saveAssistant, delOne, dsApi, validateAssistantDomain } from '@/api/embedded' import { useI18n } from 'vue-i18n' import { cloneDeep } from 'lodash-es' import { useUserStore } from '@/stores/user.ts' @@ -412,6 +412,32 @@ const certificateRules = { ], } +const domainTestLoading = ref(false) +const handleDomainTest = async () => { + const val = (currentEmbedded.domain || '').trim() + if (!val) { + ElMessage.warning(t('embedded.domain_test_empty')) + return + } + domainTestLoading.value = true + try { + const res = await validateAssistantDomain(val) + if (res.valid) { + ElMessage.success(t('embedded.domain_test_success')) + } else if (res.message === 'domain_unreachable' && res.unreachable_list?.length) { + const detail = res.unreachable_list.map((x) => `${x.url}(${x.reason})`).join(';') + ElMessage.error(t('embedded.domain_test_unreachable', { detail })) + } else { + const msg = res.invalid_value || res.message || '' + ElMessage.error(t('embedded.domain_test_fail', { msg })) + } + } catch (_e) { + ElMessage.error(t('embedded.domain_test_fail', { msg: (typeof _e === 'object' && _e && 'message' in _e) ? String((_e as any).message) : '' })) + } finally { + domainTestLoading.value = false + } +} + const preview = () => { activeStep.value = 0 } @@ -774,6 +800,9 @@ const saveHandler = () => { +
+ {{ t('embedded.cross_domain_remark') }} +
{ :placeholder="$t('embedded.third_party_address')" autocomplete="off" /> +
+ + {{ t('embedded.domain_test') }} + +
@@ -804,6 +842,9 @@ const saveHandler = () => { @submit.prevent > +
+ {{ t('embedded.interface_url_remark') }} +
{ +
+ {{ t('embedded.interface_credentials_remark') }} +
{ margin-bottom: 16px; } } + + .cross_domain_remark { + font-size: 12px; + color: var(--el-text-color-secondary, #909399); + line-height: 1.5; + margin-bottom: 8px; + } + .domain_test_wrap { + margin-top: 8px; + } }