新增: 文件操作增强与预览扩展
- 文件剪贴板复制剪切粘贴,重名自动副本 - SFTP下载到本机与外部文件拖入,目录递归 - 传输进度浮动面板,transfer-progress事件链 - Drawio预览,Excel工作线程,冻结看门狗 - cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
@@ -115,6 +115,7 @@ func StartLocalFileServer() (string, error) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||||
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||||
mux.HandleFunc("/drawio-viewer/", handleDrawioViewerRequest)
|
||||
|
||||
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
|
||||
if err != nil {
|
||||
@@ -155,26 +156,50 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
|
||||
|
||||
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
|
||||
func GetLocalFileServerAddr() string {
|
||||
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
|
||||
if localFileServer == nil {
|
||||
return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort)
|
||||
}
|
||||
return localFileServer.addr
|
||||
}
|
||||
|
||||
// writeCORSHeaders 写入 CORS 头并处理 OPTIONS 预检,返回 true 表示已处理(调用方应 return)
|
||||
func writeCORSHeaders(w http.ResponseWriter, r *http.Request) bool {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureGetMethod 检查请求是否为 GET,非 GET 返回 405,返回 true 表示不是 GET
|
||||
func ensureGetMethod(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handlePathValidationError 将路径校验错误转换为 HTTP 响应
|
||||
func handlePathValidationError(w http.ResponseWriter, err error) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,26 +220,11 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
handlePathValidationError(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
@@ -515,20 +525,7 @@ func isAbsoluteURL(path string) bool {
|
||||
// - 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)
|
||||
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -539,20 +536,11 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
handlePathValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -569,14 +557,21 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取文件所在目录(用于解析相对路径)
|
||||
baseDir := filepath.Dir(filePath)
|
||||
|
||||
// 摘出 <script>/<style> 块做占位保护,防止资源路径正则误改 JS 字符串中的 HTML 文本
|
||||
// (如模板字符串 `style="${x==='a'?...}"` 被引号重包裹破坏,导致整页脚本 SyntaxError 白屏)
|
||||
protected := protectScriptStyleBlocks(string(content), baseDir)
|
||||
|
||||
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||
processedContent := transformHtmlResourcePaths(string(content), baseDir)
|
||||
processedContent := transformHtmlResourcePaths(protected.html, baseDir)
|
||||
|
||||
// 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
|
||||
processedContent = injectPathInterceptor(processedContent, baseDir)
|
||||
|
||||
// 注入链接点击拦截脚本
|
||||
finalContent := injectLinkInterceptor(processedContent)
|
||||
processedContent = injectLinkInterceptor(processedContent)
|
||||
|
||||
// 回填受保护的 script 内容;style 内容单独走 CSS url() 重写后回填
|
||||
finalContent := restoreScriptStyleBlocks(processedContent, protected, baseDir)
|
||||
|
||||
// 返回处理后的 HTML
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -586,6 +581,69 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
|
||||
}
|
||||
|
||||
// scriptStylePlaceholder 占位标记(含随机性低的固定前缀,正常 HTML 不会出现)
|
||||
const scriptStyleMarker = "UDESK_PROTECTED_BLOCK_"
|
||||
|
||||
// protectedBlocks 记录摘出的块信息
|
||||
type protectedBlocks struct {
|
||||
scripts []string // <script>...</script> 原文(含标签),回填时不做任何改写
|
||||
styles []styleBlock
|
||||
}
|
||||
|
||||
// styleBlock 单个 <style> 块
|
||||
type styleBlock struct {
|
||||
openTag string // <style ...> 开标签
|
||||
content string // 标签间内容(CSS)
|
||||
}
|
||||
|
||||
// extractScriptStyleRegex 摘块正则(非贪婪,覆盖有无属性的 script)
|
||||
var extractScriptStyleRegex = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>|<style(\s[^>]*)?>(.*?)</style>`)
|
||||
|
||||
// protectScriptStyleBlocks 将所有 <script>/<style> 块替换为占位标记。
|
||||
// 外链 script(带 src 属性)先重写 src 指向本地服务器,内容不做任何改写。
|
||||
func protectScriptStyleBlocks(html string, baseDir string) protectedBlocksResult {
|
||||
blocks := protectedBlocks{}
|
||||
out := extractScriptStyleRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||
lower := strings.ToLower(match)
|
||||
if strings.HasPrefix(lower, "<script") {
|
||||
// 外链 script: 重写 src 属性(内联 script 无 src,attrRegex 不命中则原样)
|
||||
rewritten := replaceHtmlTagAttribute(match, htmlScriptTagRegex, "src", baseDir)
|
||||
idx := len(blocks.scripts)
|
||||
blocks.scripts = append(blocks.scripts, rewritten)
|
||||
return scriptStyleMarker + fmt.Sprintf("SCRIPT%d_", idx) + scriptStyleMarker
|
||||
}
|
||||
// <style> 块:拆开标签,内容交给后续 CSS 重写
|
||||
sm := extractScriptStyleRegex.FindStringSubmatch(match)
|
||||
// style 分支: sm[3]=属性 sm[4]=内容
|
||||
openTag := "<style>"
|
||||
if sm[3] != "" {
|
||||
openTag = "<style" + sm[3] + ">"
|
||||
}
|
||||
idx := len(blocks.styles)
|
||||
blocks.styles = append(blocks.styles, styleBlock{openTag: openTag, content: sm[4]})
|
||||
return scriptStyleMarker + fmt.Sprintf("STYLE%d_", idx) + scriptStyleMarker
|
||||
})
|
||||
return protectedBlocksResult{html: out, blocks: blocks}
|
||||
}
|
||||
|
||||
// protectedBlocksResult 摘块结果
|
||||
type protectedBlocksResult struct {
|
||||
html string
|
||||
blocks protectedBlocks
|
||||
}
|
||||
|
||||
// restoreScriptStyleBlocks 回填 script 原文;style 内容做 CSS url()/@import 重写后回填
|
||||
func restoreScriptStyleBlocks(html string, protected protectedBlocksResult, baseDir string) string {
|
||||
for i, s := range protected.blocks.scripts {
|
||||
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("SCRIPT%d_", i)+scriptStyleMarker, s, 1)
|
||||
}
|
||||
for i, b := range protected.blocks.styles {
|
||||
content := transformCssContent(b.content, baseDir)
|
||||
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("STYLE%d_", i)+scriptStyleMarker, b.openTag+content+"</style>", 1)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
|
||||
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
||||
if baseDir == "" {
|
||||
|
||||
Reference in New Issue
Block a user