Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ coverage.xml
.pytest_cache
.hypothesis

# 排除敏感配置
# 排除敏感配置(含 backend/.env,避免构建时带入本地配置)
.env
.env.local
.env.*.local
.env.*.local
backend/.env
**/.env
**/.env.local
**/.env.*.local
43 changes: 41 additions & 2 deletions backend/apps/system/api/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion backend/common/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions build/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ coverage.xml
.pytest_cache
.hypothesis

# 排除敏感配置
# 排除敏感配置(含 backend/.env,避免构建时带入本地配置)
.env
.env.local
.env.*.local
.env.*.local
backend/.env
**/.env
**/.env.local
**/.env.*.local
9 changes: 9 additions & 0 deletions frontend/src/api/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidateDomainRes>('/system/assistant/validate-domain', { domain })

export const embeddedApi = {
getList: (pageNum: any, pageSize: any, params: any) =>
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -758,16 +758,24 @@
"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",
"private": "Private",
"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",
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/i18n/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 암호화 방식을 사용합니다",
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 加密方式",
Expand Down
56 changes: 55 additions & 1 deletion frontend/src/views/system/embedded/iframe.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -774,6 +800,9 @@ const saveHandler = () => {
</el-form-item>

<el-form-item prop="domain" :label="t('embedded.cross_domain_settings')">
<div class="cross_domain_remark">
{{ t('embedded.cross_domain_remark') }}
</div>
<el-input
v-model="currentEmbedded.domain"
type="textarea"
Expand All @@ -782,6 +811,15 @@ const saveHandler = () => {
:placeholder="$t('embedded.third_party_address')"
autocomplete="off"
/>
<div class="domain_test_wrap">
<el-button
type="primary"
:loading="domainTestLoading"
@click="handleDomainTest"
>
{{ t('embedded.domain_test') }}
</el-button>
</div>
</el-form-item>
</el-form>
</div>
Expand All @@ -804,6 +842,9 @@ const saveHandler = () => {
@submit.prevent
>
<el-form-item prop="endpoint" :label="t('embedded.interface_url')">
<div class="cross_domain_remark">
{{ t('embedded.interface_url_remark') }}
</div>
<el-input
v-model="urlForm.endpoint"
clearable
Expand Down Expand Up @@ -861,6 +902,9 @@ const saveHandler = () => {
</span>
</div>
</template>
<div class="cross_domain_remark" style="margin-bottom: 8px;">
{{ t('embedded.interface_credentials_remark') }}
</div>
<div
class="table-content"
:class="!!urlForm.certificate.length && 'no-credentials_yet'"
Expand Down Expand Up @@ -1236,6 +1280,16 @@ const saveHandler = () => {
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;
}
}
</style>

Expand Down
Loading