新增: 文件操作增强与预览扩展

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+117 -59
View File
@@ -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 == "" {