- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码 - 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式 - 预览区:始终显示空白预览面板、重启自动恢复上次打开文件 - 收藏夹:简化计数显示(共N项) - 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
859 lines
27 KiB
Go
859 lines
27 KiB
Go
package filesystem
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 预编译正则表达式(避免每次调用重复编译)
|
||
var (
|
||
// CSS 相关
|
||
cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`)
|
||
cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||
|
||
// HTML 标签
|
||
htmlLinkTagRegex = regexp.MustCompile(`<link\s+([^>]*)>`)
|
||
htmlScriptTagRegex = regexp.MustCompile(`<script\s+([^>]*)>`)
|
||
htmlImgTagRegex = regexp.MustCompile(`<img\s+([^>]*)>`)
|
||
htmlVideoTagRegex = regexp.MustCompile(`<video\s+([^>]*)>`)
|
||
htmlSourceTagRegex = regexp.MustCompile(`<source\s+([^>]*)>`)
|
||
htmlAudioTagRegex = regexp.MustCompile(`<audio\s+([^>]*)>`)
|
||
htmlIframeTagRegex = regexp.MustCompile(`<iframe\s+([^>]*)>`)
|
||
htmlObjectTagRegex = regexp.MustCompile(`<object\s+([^>]*)>`)
|
||
htmlEmbedTagRegex = regexp.MustCompile(`<embed\s+([^>]*)>`)
|
||
|
||
// HTML 属性
|
||
htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`)
|
||
htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`)
|
||
htmlStyleTagRegex = regexp.MustCompile(`<style([^>]*)>([\s\S]*?)</style>`)
|
||
|
||
// 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`)
|
||
)
|
||
|
||
// 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] != '/' && 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()
|
||
|
||
// 注册 /localfs/ 路由
|
||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||
|
||
// 注册 HTML 预览专用路由
|
||
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||
|
||
// 创建服务器(固定端口)
|
||
server := &http.Server{
|
||
Addr: "localhost:8073",
|
||
Handler: mux,
|
||
}
|
||
|
||
// 启动服务器
|
||
go func() {
|
||
log.Printf("[LocalFileServer] 正在启动...")
|
||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Printf("[LocalFileServer] 启动失败: %v", err)
|
||
initErr = err
|
||
}
|
||
}()
|
||
|
||
localFileServer = &LocalFileServer{
|
||
server: server,
|
||
addr: "localhost:8073",
|
||
}
|
||
|
||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||
})
|
||
|
||
if localFileServer == nil {
|
||
return "", initErr
|
||
}
|
||
return localFileServer.addr, initErr
|
||
}
|
||
|
||
// 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, "/") && !regexp.MustCompile(`^[A-Za-z]:`).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. 处理 <style> 标签内的内容
|
||
result = replaceHtmlStyleTagContent(result, baseDir)
|
||
|
||
// 5. 处理 ES6 模块语句(使用预编译正则)
|
||
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
|
||
submatches := es6ImportFromRegex.FindStringSubmatch(match)
|
||
if len(submatches) < 3 {
|
||
return match
|
||
}
|
||
modulePath := submatches[2]
|
||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
|
||
return match
|
||
}
|
||
return fmt.Sprintf(`import %s from "%s"`, submatches[1], resolveHtmlPathToUrl(baseDir, modulePath))
|
||
})
|
||
|
||
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:") {
|
||
return match
|
||
}
|
||
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
|
||
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string {
|
||
return pattern.ReplaceAllStringFunc(html, func(match string) string {
|
||
// 提取属性值(使用缓存的正则)
|
||
var attrRegex *regexp.Regexp
|
||
if v, ok := attrRegexCache.Load(attrName); ok {
|
||
attrRegex = v.(*regexp.Regexp)
|
||
} else {
|
||
attrRegex = regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName))
|
||
attrRegexCache.Store(attrName, attrRegex)
|
||
}
|
||
attrMatch := attrRegex.FindStringSubmatch(match)
|
||
if attrMatch == nil {
|
||
return match
|
||
}
|
||
|
||
relativePath := attrMatch[1]
|
||
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
|
||
return match
|
||
}
|
||
|
||
newUrl := resolveHtmlPathToUrl(baseDir, relativePath)
|
||
return strings.Replace(match, attrMatch[0], fmt.Sprintf(`%s="%s"`, attrName, newUrl), 1)
|
||
})
|
||
}
|
||
|
||
// replaceHtmlSrcset 处理 srcset 属性
|
||
func replaceHtmlSrcset(html string, baseDir string) string {
|
||
return htmlSrcsetRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||
submatches := htmlSrcsetRegex.FindStringSubmatch(match)
|
||
if len(submatches) < 2 {
|
||
return match
|
||
}
|
||
|
||
srcset := submatches[1]
|
||
var newItems []string
|
||
for _, item := range strings.Split(srcset, ",") {
|
||
parts := strings.Fields(strings.TrimSpace(item))
|
||
if len(parts) == 0 {
|
||
continue
|
||
}
|
||
url := parts[0]
|
||
descriptor := ""
|
||
if len(parts) > 1 {
|
||
descriptor = " " + strings.Join(parts[1:], " ")
|
||
}
|
||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||
newItems = append(newItems, item)
|
||
} else {
|
||
newItems = append(newItems, resolveHtmlPathToUrl(baseDir, url)+descriptor)
|
||
}
|
||
}
|
||
return fmt.Sprintf(`srcset="%s"`, strings.Join(newItems, ", "))
|
||
})
|
||
}
|
||
|
||
// replaceHtmlInlineStyleUrls 处理内联 style 属性中的 url()
|
||
func replaceHtmlInlineStyleUrls(html string, baseDir string) string {
|
||
return htmlStyleAttrRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||
submatches := htmlStyleAttrRegex.FindStringSubmatch(match)
|
||
if len(submatches) < 2 {
|
||
return match
|
||
}
|
||
|
||
styleContent := submatches[1]
|
||
newStyle := cssUrlRegex.ReplaceAllStringFunc(styleContent, func(urlMatch string) string {
|
||
urlSubmatches := cssUrlRegex.FindStringSubmatch(urlMatch)
|
||
if len(urlSubmatches) < 2 {
|
||
return urlMatch
|
||
}
|
||
url := strings.TrimSpace(urlSubmatches[1])
|
||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||
return urlMatch
|
||
}
|
||
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||
})
|
||
return fmt.Sprintf(`style="%s"`, newStyle)
|
||
})
|
||
}
|
||
|
||
// replaceHtmlStyleTagContent 处理 <style> 标签内的内容
|
||
func replaceHtmlStyleTagContent(html string, baseDir string) string {
|
||
return htmlStyleTagRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||
submatches := htmlStyleTagRegex.FindStringSubmatch(match)
|
||
if len(submatches) < 3 {
|
||
return match
|
||
}
|
||
|
||
attrs := submatches[1]
|
||
content := submatches[2]
|
||
|
||
// 处理 @import(使用预编译正则)
|
||
content = cssImportRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||
sm := cssImportRegex.FindStringSubmatch(m)
|
||
if len(sm) < 2 {
|
||
return m
|
||
}
|
||
url := sm[1]
|
||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||
return m
|
||
}
|
||
return fmt.Sprintf(`@import url("%s");`, resolveHtmlPathToUrl(baseDir, url))
|
||
})
|
||
|
||
// 处理 url()(使用预编译正则)
|
||
content = cssUrlRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||
sm := cssUrlRegex.FindStringSubmatch(m)
|
||
if len(sm) < 2 {
|
||
return m
|
||
}
|
||
url := strings.TrimSpace(sm[1])
|
||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||
return m
|
||
}
|
||
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||
})
|
||
|
||
return fmt.Sprintf(`<style%s>%s</style>`, 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 := `
|
||
<script>
|
||
document.addEventListener('click', function(e) {
|
||
var target = e.target;
|
||
while (target && target.tagName !== 'A') {
|
||
target = target.parentElement;
|
||
}
|
||
if (target && target.tagName === 'A') {
|
||
var href = target.getAttribute('href');
|
||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
window.parent.postMessage({ type: 'openLocalFile', path: href }, '*');
|
||
}
|
||
}
|
||
}, true);
|
||
</script>
|
||
`
|
||
|
||
// 在 </body> 前插入
|
||
if strings.Contains(htmlContent, "</body>") {
|
||
return strings.Replace(htmlContent, "</body>", script+"</body>", 1)
|
||
}
|
||
// 没有 body 标签,在末尾插入
|
||
return htmlContent + script
|
||
}
|