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
2 changes: 1 addition & 1 deletion internal/handler/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions internal/logic/qa_logic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
49 changes: 48 additions & 1 deletion internal/logic/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand All @@ -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
}
86 changes: 86 additions & 0 deletions internal/logic/settings_validation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
67 changes: 34 additions & 33 deletions web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<domain>/` | 按业务域组织(apikey/、workspace/、project/、pin/、profile/、qa/、preview/、pages/、interact/、llm/、ssh/、repowiki/、settings/) |
| 新增题型组件 | `src/components/interact/question-*.tsx` | 遵循 `question-<type>.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 <name>` 添加到共享包 |
| 新增 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/<domain>/` | 按业务域组织(apikey/、workspace/、project/、pin/、profile/、qa/、preview/、pages/、interact/、llm/、ssh/、repowiki/、settings/) |
| 新增题型组件 | `src/components/interact/question-*.tsx` | 遵循 `question-<type>.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 <name>` 添加到共享包 |
| 新增 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()`) |

## 约定

Expand Down
4 changes: 1 addition & 3 deletions web/src/components/apikey/edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ export function EditDialog({ open, onOpenChange, item }: EditDialogProps) {
<DialogContent>
<DialogHeader>
<DialogTitle>编辑令牌</DialogTitle>
<DialogDescription>
修改令牌的名称、描述和状态。
</DialogDescription>
<DialogDescription>修改令牌的名称、描述和状态。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
Expand Down
5 changes: 1 addition & 4 deletions web/src/components/data-table-pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ export function DataTablePagination({
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label
htmlFor={pageSizeId}
className="text-sm text-muted-foreground"
>
<label htmlFor={pageSizeId} className="text-sm text-muted-foreground">
每页
</label>
<select
Expand Down
Loading
Loading