package filesystem import ( "context" "errors" "fmt" "log" "net" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "time" ) const DefaultFileServerPort = 2652 // 预编译正则表达式(避免每次调用重复编译) var ( // CSS 相关 cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`) cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`) // HTML 标签 htmlLinkTagRegex = regexp.MustCompile(`]*)>`) htmlScriptTagRegex = regexp.MustCompile(`]*)>`) htmlImgTagRegex = regexp.MustCompile(`]*)>`) htmlVideoTagRegex = regexp.MustCompile(`]*)>`) htmlSourceTagRegex = regexp.MustCompile(`]*)>`) htmlAudioTagRegex = regexp.MustCompile(`]*)>`) htmlIframeTagRegex = regexp.MustCompile(`]*)>`) htmlObjectTagRegex = regexp.MustCompile(`]*)>`) htmlEmbedTagRegex = regexp.MustCompile(`]*)>`) // HTML 属性 htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`) htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`) htmlStyleTagRegex = regexp.MustCompile(`]*)>([\s\S]*?)`) // ES6 模块语句 es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`) es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`) es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`) // HTML 预览路径修复 locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`) winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`) ) // 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 } // 去除代理引入的 /localfs/ 前缀(可能有多层) clean := decodedPath for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") { clean = strings.TrimPrefix(clean, "/localfs/") clean = strings.TrimPrefix(clean, "localfs/") } // 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠 filePath := filepath.FromSlash(clean) filePath = filepath.Clean(filePath) // 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头) if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && (len(filePath) < 2 || filePath[1] != ':') { filePath = "/" + filePath } if !isSafePath(filePath) { return "", ErrPathUnsafe } return filePath, nil } // LocalFileServer 本地文件服务器(独立的 HTTP 服务器) type LocalFileServer struct { server *http.Server addr string mu sync.RWMutex } var ( localFileServer *LocalFileServer localFileServerOnce sync.Once ) // StartLocalFileServer 启动本地文件服务器(端口被占用时自动递增) func StartLocalFileServer() (string, error) { var initErr error localFileServerOnce.Do(func() { mux := http.NewServeMux() mux.HandleFunc("/localfs/", handleLocalFileRequest) mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest) addr, srv, err := listenWithFallback(DefaultFileServerPort, mux) if err != nil { initErr = fmt.Errorf("无法绑定端口(%d起始): %w", DefaultFileServerPort, err) return } localFileServer = &LocalFileServer{server: srv, addr: addr} log.Printf("[LocalFileServer] 已启动,监听: %s", addr) }) if localFileServer == nil { return "", initErr } return localFileServer.addr, initErr } // listenWithFallback 从 basePort 开始尝试绑定,递增直到成功(最多试 10 次) // 返回的 server 已在 goroutine 中 Serve(l),调用方无需再启动 func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *http.Server, err error) { for offset := 0; offset < 10; offset++ { port := basePort + offset addr = fmt.Sprintf("localhost:%d", port) l, e := net.Listen("tcp", addr) if e == nil { srv = &http.Server{Handler: handler} go func() { if se := srv.Serve(l); se != http.ErrServerClosed { log.Printf("[LocalFileServer] 异常退出: %v", se) } }() return addr, srv, nil } log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1) } return "", nil, fmt.Errorf("端口 %d-%d 均不可用", basePort, basePort+9) } // GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口) func GetLocalFileServerAddr() string { if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) } return localFileServer.addr } // handleLocalFileRequest 处理本地文件请求 func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { // CORS 头:允许所有源访问(因为这是本地文件服务器) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") // 处理 OPTIONS 预检请求 if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } // 只处理 GET 请求 if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path) // 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀) pathPart := r.URL.Path for strings.HasPrefix(pathPart, "/localfs/") { pathPart = strings.TrimPrefix(pathPart, "/localfs/") } if pathPart == "" || pathPart == r.URL.Path { log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path) http.Error(w, "Invalid path", http.StatusBadRequest) return } // 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /) if !strings.HasPrefix(pathPart, "/") && !winDriveRegex.MatchString(pathPart) { pathPart = "/" + pathPart } if pathPart == "" || pathPart == r.URL.Path { log.Printf("[LocalFileHandler] 路径前缀无效") http.Error(w, "Invalid path. Use: /localfs/C:/path/to/file", http.StatusBadRequest) return } // 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查) filePath, err := validateFilePath(pathPart, "[LocalFileHandler]") if err != nil { 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] 最终路径: %s", filePath) // 🔒 文件类型白名单检查 ext := strings.ToLower(filepath.Ext(filePath)) if !isAllowedFileType(ext) { log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext) http.Error(w, fmt.Sprintf("Forbidden file type: %s", ext), http.StatusForbidden) return } // 检查文件是否存在 fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { log.Printf("[LocalFileHandler] 文件不存在: %s", filePath) http.Error(w, fmt.Sprintf("File not found: %s", filePath), http.StatusNotFound) } else { log.Printf("[LocalFileHandler] 无法访问文件: %v", err) http.Error(w, fmt.Sprintf("Failed to stat file: %v", err), http.StatusInternalServerError) } return } // 🔒 限制文件大小(最大500MB) const maxFileSize = 500 * 1024 * 1024 if fileInfo.Size() > maxFileSize { log.Printf("[LocalFileHandler] 文件过大: %d bytes", fileInfo.Size()) http.Error(w, "File too large", http.StatusForbidden) return } // CSS 文件特殊处理:转换内容中的相对路径 if ext == ".css" { content, err := os.ReadFile(filePath) if err != nil { log.Printf("[LocalFileHandler] 读取CSS文件失败: %v", err) http.Error(w, fmt.Sprintf("Failed to read CSS file: %v", err), http.StatusInternalServerError) return } // 获取 CSS 文件所在目录作为 basePath basePath := filepath.Dir(filePath) // 转换内容中的相对路径 transformedContent := transformCssContent(string(content), basePath) w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write([]byte(transformedContent)) log.Printf("[LocalFileHandler] CSS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent)) return } // JavaScript 文件特殊处理:转换动态 import 路径 if ext == ".js" || ext == ".mjs" { content, err := os.ReadFile(filePath) if err != nil { log.Printf("[LocalFileHandler] 读取JS文件失败: %v", err) http.Error(w, fmt.Sprintf("Failed to read JS file: %v", err), http.StatusInternalServerError) return } // 获取 JS 文件所在目录作为 basePath basePath := filepath.Dir(filePath) // 转换动态 import 路径 transformedContent := transformJsDynamicImports(string(content), basePath) w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write([]byte(transformedContent)) log.Printf("[LocalFileHandler] JS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent)) return } // 打开文件 file, err := os.Open(filePath) if err != nil { log.Printf("[LocalFileHandler] 打开文件失败: %v", err) http.Error(w, fmt.Sprintf("Failed to open file: %v", err), http.StatusInternalServerError) return } defer file.Close() // 设置响应头 contentType := getContentType(ext) w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=3600") // 支持 Range 请求 w.Header().Set("Accept-Ranges", "bytes") // 获取文件信息(用于 Range 请求) fileStat, err := file.Stat() if err != nil { log.Printf("[LocalFileHandler] 获取文件信息失败: %v", err) http.Error(w, fmt.Sprintf("Failed to stat file: %v", err), http.StatusInternalServerError) return } // 使用 http.ServeContent 实现流式传输(支持 Range 请求) http.ServeContent(w, r, filepath.Base(filePath), fileStat.ModTime(), file) log.Printf("[LocalFileHandler] 文件传输成功: %s (%d bytes)", filePath, fileStat.Size()) } // LocalFileHandler 本地文件处理器(兼容旧代码) // 用于直接从文件系统提供文件,避免 base64 编码 type LocalFileHandler struct { http.Handler } // NewLocalFileHandler 创建本地文件处理器 func NewLocalFileHandler() *LocalFileHandler { // 启动本地文件服务器 go func() { if _, err := StartLocalFileServer(); err != nil { log.Printf("[LocalFileHandler] 启动本地文件服务器失败: %v", err) } }() return &LocalFileHandler{} } // ServeHTTP 处理 HTTP 请求(代理到 handleLocalFileRequest) func (h *LocalFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { log.Printf("[LocalFileHandler.ServeHTTP] 收到请求: %s (RawPath: %s)", r.URL.Path, r.URL.RawPath) // 检查是否是 /localfs/ 请求 if !strings.HasPrefix(r.URL.Path, "/localfs/") { log.Printf("[LocalFileHandler.ServeHTTP] 路径不匹配 /localfs/ 前缀,返回404") // 不是 /localfs/ 请求,返回 404 http.NotFound(w, r) return } // 直接调用实际的请求处理器 handleLocalFileRequest(w, r) } // getContentType 根据文件扩展名返回 MIME 类型 // 使用统一的文件类型管理器 func getContentType(ext string) string { return defaultFileTypeManager.GetMIMEType(ext) } // isAllowedFileType 检查文件类型是否在白名单中 func isAllowedFileType(ext string) bool { return defaultFileTypeManager.IsAllowed(ext) } // Shutdown 优雅关闭文件服务器 func (lfs *LocalFileServer) Shutdown() error { if lfs == nil || lfs.server == nil { return nil } lfs.mu.Lock() defer lfs.mu.Unlock() // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() log.Printf("[LocalFileServer] 正在关闭...") if err := lfs.server.Shutdown(ctx); err != nil { log.Printf("[LocalFileServer] 关闭失败: %v", err) return err } log.Printf("[LocalFileServer] 已关闭") return nil } // ShutdownLocalFileServer 关闭全局文件服务器 func ShutdownLocalFileServer() error { if localFileServer != nil { return localFileServer.Shutdown() } return nil } // transformCssContent 转换 CSS 内容中的相对路径 // basePath: CSS 文件所在目录的绝对路径 func transformCssContent(content string, basePath string) string { // 1. 处理 @import 语句 content = cssImportRegex.ReplaceAllStringFunc(content, func(match string) string { submatches := cssImportRegex.FindStringSubmatch(match) if len(submatches) < 2 { return match } relativePath := submatches[1] // 跳过绝对 URL 和数据 URI if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") { return match } absolutePath := resolveCssRelativePath(basePath, relativePath) if absolutePath == "" { return match } return fmt.Sprintf(`@import url("%s");`, toLocalServerUrl(absolutePath)) }) // 2. 处理 url() 语句 content = cssUrlRegex.ReplaceAllStringFunc(content, func(match string) string { submatches := cssUrlRegex.FindStringSubmatch(match) if len(submatches) < 2 { return match } relativePath := strings.TrimSpace(submatches[1]) // 跳过绝对 URL、数据 URI 和绝对路径 if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") || strings.HasPrefix(relativePath, "/") || relativePath == "" { return match } absolutePath := resolveCssRelativePath(basePath, relativePath) if absolutePath == "" { return match } return fmt.Sprintf(`url("%s")`, toLocalServerUrl(absolutePath)) }) return content } // resolveCssRelativePath 解析 CSS 中的相对路径为绝对路径 func resolveCssRelativePath(basePath, relativePath string) string { // 清理路径 relativePath = strings.TrimSpace(relativePath) if relativePath == "" { return "" } // 移除 ./ 前缀 relativePath = strings.TrimPrefix(relativePath, "./") // 使用 filepath.Join 处理 ../ 等 // 注意:需要先将 / 转换为 \ 以便 Windows 路径正确处理 relativePath = strings.ReplaceAll(relativePath, "/", string(filepath.Separator)) absolutePath := filepath.Join(basePath, relativePath) // 清理路径并转换回 / absolutePath = filepath.Clean(absolutePath) absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") return absolutePath } // toLocalServerUrl 将绝对路径转换为 /localfs/ URL(带 URL 编码) func toLocalServerUrl(absolutePath string) string { // 确保路径使用 / absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") // 对路径进行 URL 编码(分段编码,保留 /) parts := strings.Split(absolutePath, "/") for i, part := range parts { // Windows 驱动器字母(如 E:)需要特殊处理,冒号必须编码 if len(part) == 2 && part[1] == ':' { // 将 "E:" 转换为 "E%3A" parts[i] = string(part[0]) + "%3A" } else { parts[i] = url.PathEscape(part) } } return "/localfs/" + strings.Join(parts, "/") } // isAbsoluteURL 检查是否为绝对 URL(http://, https://, //) func isAbsoluteURL(path string) bool { path = strings.ToLower(path) return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "//") } // handleHtmlPreviewRequest 处理 HTML 预览请求 // 参数: // - path: HTML 文件绝对路径(URL 编码) // - theme: 主题(light / dark) func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) { // CORS w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") // 处理 OPTIONS 预检请求 if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } // 只处理 GET 请求 if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 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) // 读取文件 content, err := os.ReadFile(filePath) if err != nil { log.Printf("[HtmlPreview] 读取文件失败: %v", err) http.Error(w, "File not found", http.StatusNotFound) return } // 获取文件所在目录(用于解析相对路径) baseDir := filepath.Dir(filePath) // 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL) processedContent := transformHtmlResourcePaths(string(content), baseDir) // 修复 JS 中基于 location.pathname 的相对路径计算 // 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致 // ⚠️ 会替换所有出现位置(含JS字符串内),HTML预览场景下可接受 correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/` processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname) // 注入链接点击拦截脚本 finalContent := injectLinkInterceptor(processedContent) // 返回处理后的 HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Write([]byte(finalContent)) log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent)) } // transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL func transformHtmlResourcePaths(htmlContent string, baseDir string) string { if baseDir == "" { return htmlContent } result := htmlContent // 1. 处理 HTML 标签资源 tagConfigs := []struct { pattern *regexp.Regexp attr string }{ {htmlLinkTagRegex, "href"}, {htmlScriptTagRegex, "src"}, {htmlImgTagRegex, "src"}, {htmlVideoTagRegex, "src"}, {htmlVideoTagRegex, "poster"}, {htmlSourceTagRegex, "src"}, {htmlAudioTagRegex, "src"}, {htmlIframeTagRegex, "src"}, {htmlObjectTagRegex, "data"}, {htmlEmbedTagRegex, "src"}, } for _, config := range tagConfigs { result = replaceHtmlTagAttribute(result, config.pattern, config.attr, baseDir) } // 2. 处理 srcset 属性 result = replaceHtmlSrcset(result, baseDir) // 3. 处理内联 style 属性中的 url() result = replaceHtmlInlineStyleUrls(result, baseDir) // 4. 处理 `, attrs, content) }) } // resolveHtmlPathToUrl 解析路径并转换为本地文件服务器 URL // 支持:相对路径 (./xxx, ../xxx)、绝对路径 (/xxx) func resolveHtmlPathToUrl(baseDir string, path string) string { path = strings.TrimSpace(path) if path == "" { return "" } // 统一使用 / 作为分隔符 baseDir = strings.ReplaceAll(baseDir, "\\", "/") // 处理以 / 开头的绝对路径(相对于网站根目录) // 对于 dist/index.html,/assets/... 应该被解析为 dist/assets/... if strings.HasPrefix(path, "/") { path = path[1:] // 移除开头的 / } // 移除 ./ 前缀 path = strings.TrimPrefix(path, "./") // 使用 filepath.Join 进行路径拼接(它会自动处理 ../) pathBackslash := strings.ReplaceAll(path, "/", string(filepath.Separator)) absolutePath := filepath.Join(baseDir, pathBackslash) // 清理路径并转换回 / absolutePath = filepath.Clean(absolutePath) absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") return toLocalServerUrl(absolutePath) } // transformJsDynamicImports 转换 JavaScript 文件中的 import 路径 func transformJsDynamicImports(jsContent string, baseDir string) string { if baseDir == "" { return jsContent } result := jsContent // 处理动态 import: import("...")(使用预编译正则) result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string { submatches := es6DynamicImport.FindStringSubmatch(match) if len(submatches) < 2 { return match } modulePath := submatches[1] if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { return match } return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath)) }) // 处理静态 import: import ... from "..."(使用预编译正则) result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string { submatches := es6ImportFromRegex.FindStringSubmatch(match) if len(submatches) < 3 { return match } importClause := submatches[1] modulePath := submatches[2] if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { return match } return fmt.Sprintf(`import %s from "%s"`, importClause, resolveHtmlPathToUrl(baseDir, modulePath)) }) // 处理裸 import: import "..."(使用预编译正则) result = es6BareImport.ReplaceAllStringFunc(result, func(match string) string { submatches := es6BareImport.FindStringSubmatch(match) if len(submatches) < 2 { return match } modulePath := submatches[1] if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { return match } return fmt.Sprintf(`import "%s"`, resolveHtmlPathToUrl(baseDir, modulePath)) }) return result } // injectLinkInterceptor 注入链接点击拦截脚本 func injectLinkInterceptor(htmlContent string) string { script := ` ` // 在 前插入 if strings.Contains(htmlContent, "") { return strings.Replace(htmlContent, "", script+"", 1) } // 没有 body 标签,在末尾插入 return htmlContent + script }