diff --git a/internal/handler/preview.go b/internal/handler/preview.go index 08b6d4b..531e745 100644 --- a/internal/handler/preview.go +++ b/internal/handler/preview.go @@ -110,7 +110,7 @@ func (h *PreviewHandler) GetFile(ctx *gin.Context) { // 对可执行 MIME 施加 sandbox,防止上传的恶意 HTML/SVG 在 Lumina 同源下执行脚本窃取令牌 // 存储的 HTML MIME 为常量 PreviewMimeHTML("text/html; charset=utf-8"),故用 HasPrefix 匹配 if mime := strings.ToLower(file.MimeType); strings.HasPrefix(mime, "text/html") || mime == "image/svg+xml" || strings.Contains(mime, "javascript") { - ctx.Header("Content-Security-Policy", "sandbox allow-scripts") + ctx.Header("Content-Security-Policy", "sandbox allow-scripts allow-popups") } ctx.Data(http.StatusOK, file.MimeType, []byte(file.Content)) } diff --git a/internal/handler/serve.go b/internal/handler/serve.go index 4c70051..b92c044 100644 --- a/internal/handler/serve.go +++ b/internal/handler/serve.go @@ -70,7 +70,7 @@ func writeServedFile(ctx *gin.Context, filename, mimeType, content string) { } if strings.HasPrefix(mime, "text/html") || mime == "image/svg+xml" || strings.Contains(mime, "javascript") { - ctx.Header("Content-Security-Policy", "sandbox allow-scripts") + ctx.Header("Content-Security-Policy", "sandbox allow-scripts allow-popups") } if strings.HasPrefix(mime, "text/html") { body = injectNavigateShim(body) diff --git a/internal/logic/qa_logic.go b/internal/logic/qa_logic.go index 4d37fe9..c7515d5 100644 --- a/internal/logic/qa_logic.go +++ b/internal/logic/qa_logic.go @@ -378,6 +378,9 @@ func (l *QaLogic) UpdateQaConfig(ctx context.Context, req *qa.UpdateQaConfigRequ // 更新运行时域名 if req.RuntimeDomain != nil { + if xErr := ValidateSiteDomain(ctx, *req.RuntimeDomain); xErr != nil { + return nil, xErr + } if xErr := l.repo.info.UpdateValue(ctx, bConst.InfoKeySiteDomain, *req.RuntimeDomain); xErr != nil { return nil, xError.NewError(ctx, xError.DatabaseError, "更新运行时域名失败", false, nil) } diff --git a/internal/logic/settings.go b/internal/logic/settings.go index 7215a2c..3abde98 100644 --- a/internal/logic/settings.go +++ b/internal/logic/settings.go @@ -3,7 +3,10 @@ package logic import ( "context" "fmt" + "net/url" "strconv" + "strings" + "unicode" apiSettings "github.com/xiaolfeng/Lumina/api/settings" bConst "github.com/xiaolfeng/Lumina/internal/constant" @@ -155,9 +158,47 @@ func (l *SettingsLogic) GetSettingBool(ctx context.Context, key string) (bool, * return value == "true", nil } +// ValidateSiteDomain 校验对外站点域名格式。 +// +// 允许空字符串(未配置时回退到默认监听地址)。 +// 非空时必须为合法的 HTTP 或 HTTPS 绝对地址,拥有非空 Host, +// 禁止包含账号凭据 (userinfo)、查询参数 (?)、锚点 (#)、控制字符或空白字符。 +func ValidateSiteDomain(ctx context.Context, value string) *xError.Error { + if value == "" { + return nil + } + + // 拦截包含任何空白字符或控制字符(包含 Unicode 空格、行分隔符等) + for _, r := range value { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("站点域名不得包含空白字符或控制字符"), false, nil) + } + } + + // 拦截命令行特殊符号、查询参数与锚点标识 + if strings.ContainsAny(value, "\"'`$<>|&?#\\") { + return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("站点域名必须为合法的 HTTP 或 HTTPS 根地址,不得包含查询参数 (?)、页面锚点 (#) 或特殊字符"), false, nil) + } + + parsedURL, err := url.ParseRequestURI(value) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { + return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("站点域名必须为合法的 HTTP 或 HTTPS 地址(例如 https://lumina.example.com)"), false, err) + } + + if parsedURL.User != nil { + return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("站点域名不得包含账号或密码凭据信息"), false, nil) + } + + if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { + return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("站点域名不得包含查询参数或页面锚点"), false, nil) + } + + return nil +} + // validateSettingValue 根据 KeyDef.Type 校验值合法性 // -// int → strconv.Atoi 必须成功;bool → 必须为 "true" 或 "false";string → 不校验。 +// int → strconv.Atoi 必须成功;bool → 必须为 "true" 或 "false";string → 特定键(如 site.domain)校验合法 URL 格式。 func validateSettingValue(ctx context.Context, def bConst.SettingKeyDef, value string) *xError.Error { switch def.Type { case "int": @@ -172,6 +213,12 @@ func validateSettingValue(ctx context.Context, def bConst.SettingKeyDef, value s if value != "true" && value != "false" { return xError.NewError(ctx, xError.BadRequest, xError.ErrMessage("设置项 ["+def.Key+"] 值 ["+value+"] 不是有效的布尔值(true/false)"), false, nil) } + case "string": + if def.Key == bConst.InfoKeySiteDomain { + if xErr := ValidateSiteDomain(ctx, value); xErr != nil { + return xErr + } + } } return nil } diff --git a/internal/logic/settings_validation_test.go b/internal/logic/settings_validation_test.go new file mode 100644 index 0000000..649fd17 --- /dev/null +++ b/internal/logic/settings_validation_test.go @@ -0,0 +1,86 @@ +package logic + +import ( + "context" + "testing" + + bConst "github.com/xiaolfeng/Lumina/internal/constant" +) + +func TestValidateSettingValue_SiteDomain(t *testing.T) { + ctx := context.Background() + def := bConst.SettingKeyDef{ + Key: bConst.InfoKeySiteDomain, + Category: bConst.SettingCategorySite, + Type: "string", + } + + validCases := []string{ + "", + "https://lumina.example.com", + "http://localhost:8080", + "https://127.0.0.1:8800/subpath", + } + + for _, val := range validCases { + if err := validateSettingValue(ctx, def, val); err != nil { + t.Errorf("validateSettingValue should allow %q, got error: %v", val, err) + } + } + + invalidCases := []string{ + "not-a-url", + "javascript:alert(1)", + "https://lumina.example.com; rm -rf /", + "https://lumina.example.com/foo bar", + "https://lumina.example.com\" && echo evil", + "https://lumina.example.com\nmalicious", + "ftp://lumina.example.com", + "https://lumina.example.com?x=1", + "https://lumina.example.com/foo#bar", + "https://user:pass@lumina.example.com", + "https://lumina.example.com/\u2028evil", + } + + for _, val := range invalidCases { + if err := validateSettingValue(ctx, def, val); err == nil { + t.Errorf("validateSettingValue should reject %q, but got nil", val) + } + } +} + +func TestValidateSiteDomain_Direct(t *testing.T) { + ctx := context.Background() + + // 合法用例 + valid := []string{ + "", + "https://example.com", + "http://localhost:3000", + "https://sub.domain.org/path", + "http://192.168.1.1:8800", + } + for _, v := range valid { + if err := ValidateSiteDomain(ctx, v); err != nil { + t.Errorf("ValidateSiteDomain(%q) expected nil, got: %v", v, err) + } + } + + // 非法用例 + invalid := []string{ + "ftp://example.com", + "https://example.com?query=1", + "https://example.com#hash", + "https://user:pass@example.com", + "https://example.com/foo bar", + "https://example.com\nmalicious", + "https://example.com; rm -rf /", + "https://example.com`whoami`", + "https://example.com/\u2028split", + } + for _, inv := range invalid { + if err := ValidateSiteDomain(ctx, inv); err == nil { + t.Errorf("ValidateSiteDomain(%q) expected error, got nil", inv) + } + } +} diff --git a/web/AGENTS.md b/web/AGENTS.md index 3233d10..4d9c74c 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -7,6 +7,7 @@ TanStack Start(React 19)+ Tailwind CSS 4 + shadcn/ui 构建的 Lumina 前端应用,通过 REST API + WebSocket 与后端通信。前端构建产物通过 `go:embed` 嵌入 Go 二进制,支持单文件部署。同时通过 `@lumina/components` workspace 包与 `web-wiki` 共享 shadcn/ui 组件、Markdown 原语、motion 动画变体和微明主题 CSS。 ## 目录结构 + ```text web/ ├── package.json # pnpm 管理;React 19 + TanStack Start + Tailwind CSS 4 @@ -285,39 +286,39 @@ web/ ## 导航指南 -| 任务 | 位置 | 说明 | -|---|---|---| -| 新增页面 | `src/routes/` | 文件路径即路由路径;布局路由以 `_` 前缀 | -| 新增控制台子页面 | `src/routes/console/` | 在 `console.tsx` 布局下添加,自动继承 Sidebar + Breadcrumb | -| 新增工作空间管理 | `src/routes/console/workspace.tsx` | 工作空间增删改查、默认空间保障与图标配置 | -| 新增 Pages 管理页 | `src/routes/console/pages/` | 已发布页面列表、版本查询、访问策略与密码门设置 | -| 新增 Pages 展示页 | `src/routes/pages/` | 路径式寻址 `/pages/:projectName/:slug`,密码门认证、沉浸壳展示 | -| 新增 Preview 工作台 | `src/routes/preview/$sessionHash/$.tsx` | 路径式寻址 `/preview/:hash/:file`,视口缩放、源码查看、晋升对话框 | -| 新增 LPW 文档渲染 | `@lumina/components/lpw` | 25+ 块组件与 4 种容器,支持 React 直渲与 ECharts 懒加载 | -| 新增 MCP 接入说明 | `src/routes/console/connect.tsx` | 四级信道:插件安装 / 手动 MCP / 技能安装 / 工具速览;模板在 `mcp-connect.ts` + `plugin-connect.ts` | -| 新增 MCP 接入组件 | `src/components/mcp/` | 复制块/端点卡/插件安装/技能安装/手动配置/工具目录;令牌弹窗复用 `created-key-panel` | -| 新增 OAuth 同意页 | `src/routes/_public/oauth.tsx` | 公开布局;`?authorize_id=` 对应后端授权请求缓存;未登录先走登录再裁决 | -| 新增项目级子页面 | `src/routes/console/project/$projectId/` | 按模块划分子目录(如 `repowiki/`) | -| 新增 Interact 子页面 | `src/routes/interact/` | 在 `interact.tsx` 布局下添加 | -| 新增通用组件 | `src/components/` | 全局级组件(Navbar/Footer/Sidebar/通用对话框/骨架屏等) | -| 新增首页落地页区块 | `src/components/landing/` | 首页拆分为 hero/features/tech 等区块组件 | -| 新增业务组件 | `src/components//` | 按业务域组织(apikey/、workspace/、project/、pin/、profile/、qa/、preview/、pages/、interact/、llm/、ssh/、repowiki/、settings/) | -| 新增题型组件 | `src/components/interact/question-*.tsx` | 遵循 `question-.tsx` 命名,通过 `question-card.tsx` 分发 | -| 新增交互原语 | `src/components/interact/primitives/` | 可复用的展示原语(Kicker/PanelCard/SandboxFrame/PreviewFrame 等);Markdown 原语由 `@lumina/components` 提供 | -| 新增 LLM 配置组件 | `src/components/llm/` | Provider/Model CRUD + Agent 角色模型分配 | -| 新增 SSH Key 组件 | `src/components/ssh/` | CRUD 对话框 + 密钥生成入口 | -| 新增 RepoWiki 组件 | `src/components/repowiki/` | 配置表单/版本管理/分析触发/Webhook 配置 | -| 新增系统设置组件 | `src/components/settings/` | 按分组组织(站点/安全/Q&A/RepoWiki/Preview) | -| 新增 shadcn/ui 组件 | `components/src/ui/` | 通过 `pnpm dlx shadcn@latest add ` 添加到共享包 | -| 新增 API 接口 | `src/lib/apis/` | 使用 apiClient 封装,返回类型化响应 | -| 新增数据 Hook | `src/hooks/` | 基于 TanStack Query 的 useMutation/useQuery | -| 新增类型定义 | `src/lib/models/` | 按 request/response 子目录组织 | -| 新增 WebAuthn 辅助函数 | `src/lib/webauthn/helpers.ts` | 浏览器端 base64 编解码、选项解析 | -| 新增题型格式化 | `src/lib/format-answer.ts` | 各题型 answer → 可读字符串,跨 QA/interact 复用 | -| 修改全局主题色 | `components/src/styles/theme.css` | 主题色盘已迁到共享包(静烛 v1) | -| 修改路由配置 | `src/router.tsx` | 预加载策略、滚动恢复等 | -| 修改动画配置 | `components/src/motion/` | 缓动函数和全局动画变体(共享包,详见 [components/](../components/AGENTS.md)) | -| 工具函数 | `src/lib/` 或 `components/src/lib/` | 通用工具(如 `cn()`) | +| 任务 | 位置 | 说明 | +| ---------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 新增页面 | `src/routes/` | 文件路径即路由路径;布局路由以 `_` 前缀 | +| 新增控制台子页面 | `src/routes/console/` | 在 `console.tsx` 布局下添加,自动继承 Sidebar + Breadcrumb | +| 新增工作空间管理 | `src/routes/console/workspace.tsx` | 工作空间增删改查、默认空间保障与图标配置 | +| 新增 Pages 管理页 | `src/routes/console/pages/` | 已发布页面列表、版本查询、访问策略与密码门设置 | +| 新增 Pages 展示页 | `src/routes/pages/` | 路径式寻址 `/pages/:projectName/:slug`,密码门认证、沉浸壳展示 | +| 新增 Preview 工作台 | `src/routes/preview/$sessionHash/$.tsx` | 路径式寻址 `/preview/:hash/:file`,视口缩放、源码查看、晋升对话框 | +| 新增 LPW 文档渲染 | `@lumina/components/lpw` | 25+ 块组件与 4 种容器,支持 React 直渲与 ECharts 懒加载 | +| 新增 MCP 接入说明 | `src/routes/console/connect.tsx` | 四级信道:插件安装 / 手动 MCP / 技能安装 / 工具速览;模板在 `mcp-connect.ts` + `plugin-connect.ts` | +| 新增 MCP 接入组件 | `src/components/mcp/` | 复制块/端点卡/插件安装/技能安装/手动配置/工具目录;令牌弹窗复用 `created-key-panel` | +| 新增 OAuth 同意页 | `src/routes/_public/oauth.tsx` | 公开布局;`?authorize_id=` 对应后端授权请求缓存;未登录先走登录再裁决 | +| 新增项目级子页面 | `src/routes/console/project/$projectId/` | 按模块划分子目录(如 `repowiki/`) | +| 新增 Interact 子页面 | `src/routes/interact/` | 在 `interact.tsx` 布局下添加 | +| 新增通用组件 | `src/components/` | 全局级组件(Navbar/Footer/Sidebar/通用对话框/骨架屏等) | +| 新增首页落地页区块 | `src/components/landing/` | 首页拆分为 hero/features/tech 等区块组件 | +| 新增业务组件 | `src/components//` | 按业务域组织(apikey/、workspace/、project/、pin/、profile/、qa/、preview/、pages/、interact/、llm/、ssh/、repowiki/、settings/) | +| 新增题型组件 | `src/components/interact/question-*.tsx` | 遵循 `question-.tsx` 命名,通过 `question-card.tsx` 分发 | +| 新增交互原语 | `src/components/interact/primitives/` | 可复用的展示原语(Kicker/PanelCard/SandboxFrame/PreviewFrame 等);Markdown 原语由 `@lumina/components` 提供 | +| 新增 LLM 配置组件 | `src/components/llm/` | Provider/Model CRUD + Agent 角色模型分配 | +| 新增 SSH Key 组件 | `src/components/ssh/` | CRUD 对话框 + 密钥生成入口 | +| 新增 RepoWiki 组件 | `src/components/repowiki/` | 配置表单/版本管理/分析触发/Webhook 配置 | +| 新增系统设置组件 | `src/components/settings/` | 按分组组织(站点/安全/Q&A/RepoWiki/Preview) | +| 新增 shadcn/ui 组件 | `components/src/ui/` | 通过 `pnpm dlx shadcn@latest add ` 添加到共享包 | +| 新增 API 接口 | `src/lib/apis/` | 使用 apiClient 封装,返回类型化响应 | +| 新增数据 Hook | `src/hooks/` | 基于 TanStack Query 的 useMutation/useQuery | +| 新增类型定义 | `src/lib/models/` | 按 request/response 子目录组织 | +| 新增 WebAuthn 辅助函数 | `src/lib/webauthn/helpers.ts` | 浏览器端 base64 编解码、选项解析 | +| 新增题型格式化 | `src/lib/format-answer.ts` | 各题型 answer → 可读字符串,跨 QA/interact 复用 | +| 修改全局主题色 | `components/src/styles/theme.css` | 主题色盘已迁到共享包(静烛 v1) | +| 修改路由配置 | `src/router.tsx` | 预加载策略、滚动恢复等 | +| 修改动画配置 | `components/src/motion/` | 缓动函数和全局动画变体(共享包,详见 [components/](../components/AGENTS.md)) | +| 工具函数 | `src/lib/` 或 `components/src/lib/` | 通用工具(如 `cn()`) | ## 约定 diff --git a/web/src/components/apikey/edit-dialog.tsx b/web/src/components/apikey/edit-dialog.tsx index 65bc9e0..bcad45d 100644 --- a/web/src/components/apikey/edit-dialog.tsx +++ b/web/src/components/apikey/edit-dialog.tsx @@ -54,9 +54,7 @@ export function EditDialog({ open, onOpenChange, item }: EditDialogProps) { 编辑令牌 - - 修改令牌的名称、描述和状态。 - + 修改令牌的名称、描述和状态。
diff --git a/web/src/components/data-table-pagination.tsx b/web/src/components/data-table-pagination.tsx index f5e5351..b6b2634 100644 --- a/web/src/components/data-table-pagination.tsx +++ b/web/src/components/data-table-pagination.tsx @@ -28,10 +28,7 @@ export function DataTablePagination({
-