Private
Public Access
1
0
Files
u-desk/internal/filesystem/asset_handler.go

873 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`<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`)
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)
// 🔒 文件类型白名单检查(临时目录文件放行,用于 OSS/SFTP 预览)
ext := strings.ToLower(filepath.Ext(filePath))
isTemp := strings.HasPrefix(filePath, os.TempDir())
if !isTemp && !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 检查是否为绝对 URLhttp://, 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
}