Private
Public Access
1
0

优化:文件服务器安全重构+编辑器增强+搜索排序+更新面板Markdown渲染

- 路径校验提取validateFilePath+sentinel error替代字符串匹配
- requireUpdateAPI收敛7处重复nil检查
- 端口18765统一为8073,消除分散魔法数字
- CodeMirror添加搜索功能+滚动位置LRU缓存恢复
- 文件列表新增列排序+搜索过滤
- Toolbar重排:快捷访问内嵌+搜索框集成+历史改图标
- 重命名零闪烁:updateFilePath草稿迁移
- changelog用marked渲染+sanitizeHtml防XSS
- MigrateTabConfig扩展map驱动覆盖openclaw-manager→version迁移

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:53:31 +08:00
parent 691e38604f
commit 72fef3e56f
23 changed files with 614 additions and 257 deletions

View File

@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
}, nil
}
// MigrateTabConfig 迁移旧配置
// MigrateTabConfig 迁移旧配置device 移除 + openclaw-manager 重命名)
func (api *ConfigAPI) MigrateTabConfig() error {
config, _ := api.configService.GetTabConfig()
if config == nil {
return nil
}
// 检查是否包含 device
hasDevice := false
needMigrate := false
// 检查是否包含需要迁移的旧 key
for _, tab := range config.AvailableTabs {
if tab.Key == "device" {
hasDevice = true
if tab.Key == "device" || tab.Key == "openclaw-manager" {
needMigrate = true
break
}
}
if !hasDevice {
if !needMigrate {
return nil
}
// 过滤掉 device
// 映射:旧 key → 新 key不需要的移除
keyMap := map[string]string{
"openclaw-manager": "version",
// "device": "" // 直接过滤
}
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
newVisible := make([]string, 0, len(config.VisibleTabs))
seenKeys := map[string]bool{}
for _, tab := range config.AvailableTabs {
if tab.Key != "device" {
newKey, shouldRename := keyMap[tab.Key]
if shouldRename {
if newKey == "" {
continue // 移除(如 device
}
if seenKeys[newKey] {
continue // 避免重复
}
seenKeys[newKey] = true
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
} else {
newTabs = append(newTabs, tab)
}
}
for _, key := range config.VisibleTabs {
if key != "device" {
if newKey, ok := keyMap[key]; ok {
if newKey != "" && !seenKeys[newKey] {
newVisible = append(newVisible, newKey)
}
// newKey == "" 时跳过(如 device
} else {
newVisible = append(newVisible, key)
}
}
defaultTab := config.DefaultTab
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
defaultTab = newKey
}
if defaultTab == "device" {
defaultTab = "file-system"
}

View File

@@ -2,6 +2,7 @@ package filesystem
import (
"context"
"errors"
"fmt"
"log"
"net/http"
@@ -48,6 +49,35 @@ var (
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
var attrRegexCache sync.Map // map[string]*regexp.Regexp
// 路径校验 sentinel error用 errors.Is 匹配,不依赖字符串)
var (
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
ErrPathTraversal = fmt.Errorf("path traversal detected")
ErrPathUnsafe = fmt.Errorf("unsafe path")
)
// validateFilePath 校验文件路径安全性URL解码 + 路径遍历检测 + 安全检查)
// 返回清理后的绝对路径,或 sentinel error
func validateFilePath(rawPath string, logPrefix string) (string, error) {
decodedPath, err := url.QueryUnescape(rawPath)
if err != nil {
return "", ErrPathInvalidEncoding
}
if strings.Contains(decodedPath, "..") {
return "", ErrPathTraversal
}
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
filePath = filepath.Clean(filePath)
if !isSafePath(filePath) {
return "", ErrPathUnsafe
}
return filePath, nil
}
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
type LocalFileServer struct {
server *http.Server
@@ -75,7 +105,7 @@ func StartLocalFileServer() (string, error) {
// 创建服务器(固定端口)
server := &http.Server{
Addr: "localhost:18765",
Addr: "localhost:8073",
Handler: mux,
}
@@ -90,7 +120,7 @@ func StartLocalFileServer() (string, error) {
localFileServer = &LocalFileServer{
server: server,
addr: "localhost:18765",
addr: "localhost:8073",
}
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
@@ -125,7 +155,6 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效")
@@ -133,34 +162,24 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return
}
// 🔒 修复:先进行URL解码,防止路径遍历攻击
decodedPath, err := url.QueryUnescape(pathPart)
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
if err != nil {
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
// 🔒 修复:在路径转换前检查是否包含危险字符
if strings.Contains(decodedPath, "..") {
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
http.Error(w, "Path traversal detected", http.StatusForbidden)
return
}
// 路径转换(统一使用反斜杠)
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
filePath = filepath.Clean(filePath)
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
// 安全检查
if !isSafePath(filePath) {
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
http.Error(w, "Unsafe path", http.StatusForbidden)
return
}
// 🔒 文件类型白名单检查
ext := strings.ToLower(filepath.Ext(filePath))
if !isAllowedFileType(ext) {
@@ -459,33 +478,31 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
}
// 解析参数
filePath := r.URL.Query().Get("path")
var err error
if filePath, err = url.QueryUnescape(filePath); err != nil {
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
return
}
rawPath := r.URL.Query().Get("path")
theme := r.URL.Query().Get("theme")
if theme == "" {
theme = "light"
}
// 校验路径安全性URL解码 + 路径遍历检测 + 安全检查)
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
if err != nil {
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
// 安全检查
if !isSafePath(filePath) {
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
http.Error(w, "Unsafe path", http.StatusForbidden)
return
}
// 检查路径遍历攻击
if strings.Contains(filePath, "..") {
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
http.Error(w, "Path traversal detected", http.StatusForbidden)
return
}
// 读取文件
content, err := os.ReadFile(filePath)
if err != nil {

View File

@@ -44,9 +44,9 @@ var defaultTabConfig = TabConfig{
{Key: "file-system", Title: "文件管理", Enabled: true},
{Key: "db-cli", Title: "数据库", Enabled: true},
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
{Key: "version", Title: "版本历史", Enabled: true},
},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
DefaultTab: "file-system",
}