diff --git a/app.go b/app.go index e22c1e4..d5a64da 100644 --- a/app.go +++ b/app.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "net/http" "os" "path/filepath" stdruntime "runtime" @@ -30,7 +29,6 @@ type App struct { updateAPI *api.UpdateAPI configAPI *api.ConfigAPI pdfAPI *api.PdfAPI - fileServer *http.Server filesystem *filesystem.FileSystemService isAlwaysOnTop bool } @@ -194,7 +192,7 @@ func (a *App) startFileServer() { return } - fmt.Println("[文件服务器] 启动在 http://localhost:18765") + fmt.Println("[文件服务器] 启动在 http://localhost:8073") } // Shutdown 应用关闭时调用 @@ -415,7 +413,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) { // Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置) folderGUIDs := map[string]string{ "desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}", - "documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}", + "documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}", "downloads": "{374DE290-123F-4565-9164-39C4925E467B}", } for name, guid := range folderGUIDs { @@ -603,68 +601,84 @@ func (a *App) ListSqlTabs() ([]map[string]interface{}, error) { // ========== 版本更新管理接口 ========== -// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成) -func (a *App) CheckUpdate() (map[string]interface{}, error) { +// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误 +func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) { if a.updateAPI == nil { return nil, fmt.Errorf("更新功能正在初始化中") } - return a.updateAPI.CheckUpdate() + return a.updateAPI, nil +} + +// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成) +func (a *App) CheckUpdate() (map[string]interface{}, error) { + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err + } + return api.CheckUpdate() } // GetCurrentVersion 获取当前版本号 func (a *App) GetCurrentVersion() (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.GetCurrentVersion() + return api.GetCurrentVersion() } // GetUpdateConfig 获取更新配置 func (a *App) GetUpdateConfig() (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.GetUpdateConfig() + return api.GetUpdateConfig() } // SetUpdateConfig 设置更新配置 func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL) + return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL) } // DownloadUpdate 下载更新包 func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.DownloadUpdate(downloadURL) + return api.DownloadUpdate(downloadURL) } // InstallUpdate 安装更新包 func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.InstallUpdate(installerPath, autoRestart) + return api.InstallUpdate(installerPath, autoRestart) } // InstallUpdateWithHash 安装更新包(带哈希验证) func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType) + return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType) } // VerifyUpdateFile 验证更新文件哈希值 func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) { - if a.updateAPI == nil { - return nil, fmt.Errorf("更新功能正在初始化中") + api, err := a.requireUpdateAPI() + if err != nil { + return nil, err } - return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType) + return api.VerifyUpdateFile(filePath, expectedHash, hashType) } // startAutoUpdateCheck 启动自动更新检查 @@ -753,7 +767,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) { // GetFileServerURL 获取本地文件服务器的URL func (a *App) GetFileServerURL() string { - return "http://localhost:18765" + return "http://localhost:8073" } // DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件) diff --git a/build/appicon.png b/build/appicon.png index 63617fe..3e081d9 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico index bfa0690..28e7c6e 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/internal/api/config_api.go b/internal/api/config_api.go index 99e90b4..3917914 100644 --- a/internal/api/config_api.go +++ b/internal/api/config_api.go @@ -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" } diff --git a/internal/filesystem/asset_handler.go b/internal/filesystem/asset_handler.go index 5c9f32a..8a8c7b2 100644 --- a/internal/filesystem/asset_handler.go +++ b/internal/filesystem/asset_handler.go @@ -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 { diff --git a/internal/service/config_service.go b/internal/service/config_service.go index 0be8eae..bd49d58 100644 --- a/internal/service/config_service.go +++ b/internal/service/config_service.go @@ -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", } diff --git a/web/package-lock.json b/web/package-lock.json index 744cd85..09e0f43 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,6 +25,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.3", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.8", @@ -414,6 +415,17 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, "node_modules/@codemirror/state": { "version": "6.5.3", "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz", diff --git a/web/package.json b/web/package.json index d45585b..04fb609 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.3", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.8", diff --git a/web/package.json.md5 b/web/package.json.md5 index 74e7cb4..10359ee 100644 --- a/web/package.json.md5 +++ b/web/package.json.md5 @@ -1 +1 @@ -0e1fafcbb6b28922a38f6c5316932015 \ No newline at end of file +c0e9e27e045c6118704c87fcf34a03de \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue index e189bf3..b424a4b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -101,12 +101,13 @@ import FileSystem from './components/FileSystem/index.vue' import SettingsPanel from './components/SettingsPanel.vue' import UpdateNotification from './components/UpdateNotification.vue' import {useUpdateStore} from './stores/update' -import {useConfigStore} from './stores/config' +import {useConfigStore, type AppConfig} from './stores/config' // 存储键 const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab' // 从 localStorage 恢复上次打开的区域,默认为 'file-system' +// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移 const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system') const showSettings = ref(false) @@ -125,7 +126,7 @@ const appConfig = computed(() => configStore.appConfig) const visibleTabs = computed(() => configStore.visibleTabs) // 保存配置 -const handleSaveConfig = async (config) => { +const handleSaveConfig = async (config: AppConfig) => { try { await configStore.saveConfig(config) showSettings.value = false @@ -148,7 +149,7 @@ const loadConfig = async () => { } // 获取组件 -const getComponent = (key) => { +const getComponent = (key: string) => { const components = { 'file-system': FileSystem, 'db-cli': DbCli, @@ -376,4 +377,9 @@ watch(activeTab, (newTab) => { .arco-tooltip { --wails-draggable: no-drag; } + +/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */ +html, body { + overflow: hidden !important; +} diff --git a/web/src/components/CodeEditor.vue b/web/src/components/CodeEditor.vue index 3323eef..4658cdd 100644 --- a/web/src/components/CodeEditor.vue +++ b/web/src/components/CodeEditor.vue @@ -3,34 +3,25 @@