新增: 文件操作增强与预览扩展
- 文件剪贴板复制剪切粘贴,重名自动副本 - 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 == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
OperationDelete AuditOperation = "delete" // 删除文件
|
||||
OperationCreate AuditOperation = "create" // 创建目录
|
||||
OperationRename AuditOperation = "rename" // 重命名
|
||||
OperationCopy AuditOperation = "copy" // 复制
|
||||
OperationMove AuditOperation = "move" // 移动
|
||||
OperationList AuditOperation = "list" // 列出目录
|
||||
OperationDownload AuditOperation = "download" // 下载
|
||||
@@ -39,43 +41,45 @@ type AuditLogEntry struct {
|
||||
|
||||
// AuditLogger 审计日志记录器
|
||||
type AuditLogger struct {
|
||||
logFile *os.File
|
||||
logPath string
|
||||
mu sync.Mutex
|
||||
buffer []AuditLogEntry
|
||||
logFile *os.File
|
||||
logDir string
|
||||
mu sync.Mutex
|
||||
buffer []AuditLogEntry
|
||||
bufferSize int
|
||||
stopChan chan struct{}
|
||||
stopChan chan struct{}
|
||||
initialized bool // 懒初始化标志(失败时允许重试)
|
||||
}
|
||||
|
||||
// NewAuditLogger 创建审计日志记录器
|
||||
// NewAuditLogger 创建审计日志记录器(延迟初始化,不立即打开文件)
|
||||
func NewAuditLogger(logDir string) (*AuditLogger, error) {
|
||||
// 创建日志目录
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建日志目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成日志文件名(按日期)
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
|
||||
|
||||
// 打开日志文件(追加模式)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
logger := &AuditLogger{
|
||||
logFile: logFile,
|
||||
logPath: logPath,
|
||||
return &AuditLogger{
|
||||
logDir: logDir,
|
||||
buffer: make([]AuditLogEntry, 0, 100),
|
||||
bufferSize: 100, // 缓冲100条记录后批量写入
|
||||
bufferSize: 100,
|
||||
stopChan: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureReady 首次写入时才创建目录、打开文件、启动后台协程
|
||||
// 在 a.mu.Lock 保护下调用,失败时下次 Log 会重试
|
||||
func (a *AuditLogger) ensureReady() error {
|
||||
if a.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 启动后台协程,定期刷新缓冲区
|
||||
go logger.backgroundFlush()
|
||||
|
||||
return logger, nil
|
||||
if err := os.MkdirAll(a.logDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建日志目录失败: %v", err)
|
||||
}
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
f, err := os.OpenFile(
|
||||
filepath.Join(a.logDir, fmt.Sprintf("audit_%s.log", timestamp)),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.logFile = f
|
||||
a.initialized = true
|
||||
go a.backgroundFlush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log 记录操作日志
|
||||
@@ -88,6 +92,13 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 懒初始化:首次写入时才打开文件
|
||||
if a.logFile == nil {
|
||||
if err := a.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到缓冲区
|
||||
a.buffer = append(a.buffer, entry)
|
||||
|
||||
@@ -211,6 +222,11 @@ func (a *AuditLogger) Close() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 从未写入过日志,无需关闭
|
||||
if a.logFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 刷新剩余缓冲区
|
||||
if err := a.flush(); err != nil {
|
||||
return err
|
||||
@@ -251,35 +267,15 @@ func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// parseLines 解析文本为行
|
||||
// parseLines 解析文本为行(过滤空行)
|
||||
func parseLines(text string) []string {
|
||||
lines := make([]string, 0)
|
||||
current := ""
|
||||
|
||||
for _, ch := range text {
|
||||
if ch == '\n' {
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += string(ch)
|
||||
raw := strings.Split(text, "\n")
|
||||
lines := make([]string, 0, len(raw))
|
||||
for _, line := range raw {
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// 全局审计日志记录器
|
||||
var globalAuditLogger *AuditLogger
|
||||
var auditLoggerOnce sync.Once
|
||||
|
||||
// GetAuditLogger 获取全局审计日志记录器
|
||||
func GetAuditLogger() *AuditLogger {
|
||||
return globalAuditLogger
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,8 @@ func getAllowedExtensions() map[string]bool {
|
||||
".md": true,
|
||||
".json": true,
|
||||
".xml": true,
|
||||
".drawio": true,
|
||||
".dio": true,
|
||||
".html": true,
|
||||
".css": true,
|
||||
".js": true,
|
||||
@@ -369,6 +371,8 @@ func getMIMETypeMapping() map[string]string {
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".drawio": "application/xml",
|
||||
".dio": "application/xml",
|
||||
".md": "text/markdown",
|
||||
// 表格
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/service"
|
||||
)
|
||||
|
||||
// drawio viewer 运行时下载缓存方案:
|
||||
// 首次预览 .drawio 时,从自建下载源拉取 drawio webapp zip → 解压到 ~/.u-desk/drawio-viewer/
|
||||
// → 文件服务器 /drawio-viewer/ 从缓存目录托管静态资源 → 之后离线可用。
|
||||
// 升级 drawio 时:改下面的版本号 + URL + SHA256。
|
||||
|
||||
const (
|
||||
drawioViewerVersion = "30.3.8" // drawio 版本,升级时改这里
|
||||
drawioViewerZipURL = "https://c.1216.top/u-desk/drawio-viewer-30.3.8.war" // 自建下载源(draw.war 解压即 webapp)
|
||||
drawioViewerSHA256 = "e12a3e190adda7c1486572bd28c1af9fffbe932df2efead3c9aa8347862505da" // draw.war v30.3.8 SHA256
|
||||
drawioViewerDirName = "drawio-viewer"
|
||||
versionFileName = "VERSION"
|
||||
)
|
||||
|
||||
// 并发保护:多个 .drawio 同时打开时只下载一次,其余等待
|
||||
var (
|
||||
drawioEnsureMu sync.Mutex
|
||||
drawioEnsuring bool
|
||||
)
|
||||
|
||||
// DrawioViewerVersion 返回当前配置的 viewer 版本(供 binding 返回前端)
|
||||
func DrawioViewerVersion() string { return drawioViewerVersion }
|
||||
|
||||
// drawioCacheDir 缓存目录 ~/.u-desk/drawio-viewer/
|
||||
func drawioCacheDir() string {
|
||||
return filepath.Join(common.GetUserDataDir(), drawioViewerDirName)
|
||||
}
|
||||
|
||||
// isDrawioViewerReady 缓存目录存在且 VERSION 文件匹配当前版本
|
||||
func isDrawioViewerReady() bool {
|
||||
data, err := os.ReadFile(filepath.Join(drawioCacheDir(), versionFileName))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == drawioViewerVersion
|
||||
}
|
||||
|
||||
// EnsureDrawioViewer 确保本地已缓存 drawio viewer。未就绪则下载 zip + 解压。
|
||||
// progress 回调推送下载进度(可为 nil)。并发安全:同时调用只下载一次,其余等待。
|
||||
func EnsureDrawioViewer(progress service.DownloadProgress) error {
|
||||
if isDrawioViewerReady() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 并发保护:已有线程在下载 → 轮询等待就绪
|
||||
drawioEnsureMu.Lock()
|
||||
if drawioEnsuring {
|
||||
drawioEnsureMu.Unlock()
|
||||
for i := 0; i < 3600; i++ { // 最多等待 30 分钟(与下载超时对齐)
|
||||
if isDrawioViewerReady() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("等待 drawio viewer 下载超时")
|
||||
}
|
||||
drawioEnsuring = true
|
||||
drawioEnsureMu.Unlock()
|
||||
defer func() {
|
||||
drawioEnsureMu.Lock()
|
||||
drawioEnsuring = false
|
||||
drawioEnsureMu.Unlock()
|
||||
}()
|
||||
|
||||
cacheDir := drawioCacheDir()
|
||||
|
||||
// 1. 下载 zip(复用 service.DownloadUpdate,自带断点续传 + 哈希)
|
||||
res, err := service.DownloadUpdate(drawioViewerZipURL, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 drawio viewer 失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. SHA256 校验(若常量非空)。DownloadUpdate 成功时必返回 SHA256Hash
|
||||
if drawioViewerSHA256 != "" {
|
||||
if !strings.EqualFold(res.SHA256Hash, drawioViewerSHA256) {
|
||||
_ = os.Remove(res.FilePath) // 删除损坏文件,下次重试
|
||||
return fmt.Errorf("drawio viewer 哈希校验失败: 期望 %s, 实际 %s", drawioViewerSHA256, res.SHA256Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 解压到临时目录,成功后原子替换缓存目录(避免半解压状态)
|
||||
tmpDir := cacheDir + ".tmp-" + drawioViewerVersion
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
if err := unzipToDir(res.FilePath, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("解压 drawio viewer 失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 写 VERSION 文件
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, versionFileName), []byte(drawioViewerVersion), 0644); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("写入 VERSION 失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 原子替换缓存目录
|
||||
_ = os.RemoveAll(cacheDir)
|
||||
if err := os.Rename(tmpDir, cacheDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("移动 drawio viewer 到缓存目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 安装成功后删除下载的 war 包,避免长期占用磁盘(失败路径保留以支持断点续传)
|
||||
_ = os.Remove(res.FilePath)
|
||||
|
||||
log.Printf("[DrawioViewer] 缓存就绪: %s (版本 %s)", cacheDir, drawioViewerVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDrawioViewerRequest /drawio-viewer/* → 从缓存目录读静态资源(绕过文件类型白名单)
|
||||
// viewer 资源含 .woff2/.stencil 等白名单外扩展名,故独立路由,不走 handleLocalFileRequest。
|
||||
func handleDrawioViewerRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||
return
|
||||
}
|
||||
if !isDrawioViewerReady() {
|
||||
http.Error(w, "drawio viewer not ready, please call EnsureDrawioViewer first", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// 剥离 /drawio-viewer/ 前缀
|
||||
subPath := strings.TrimPrefix(r.URL.Path, "/drawio-viewer/")
|
||||
if subPath == "" {
|
||||
subPath = "index.html"
|
||||
}
|
||||
|
||||
// 路径清理 + 防遍历:限制在缓存目录内
|
||||
cleanPath, ok := sanitizeSubPath(subPath)
|
||||
if !ok {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if cleanPath == "." || cleanPath == "" {
|
||||
cleanPath = "index.html"
|
||||
}
|
||||
|
||||
absPath, ok := joinWithinBase(drawioCacheDir(), cleanPath)
|
||||
if !ok {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", getDrawioViewerMIME(filepath.Ext(cleanPath)))
|
||||
if cleanPath == "index.html" {
|
||||
w.Header().Set("Cache-Control", "no-cache") // 入口短缓存,便于升级
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// sanitizeSubPath 将子路径(URL 或 zip 条目名)清理为正斜杠相对路径,含 .. 视为可疑,返回 false
|
||||
func sanitizeSubPath(sub string) (string, bool) {
|
||||
cleaned := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+sub)), "/")
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", false
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
// joinWithinBase 将相对路径拼进 base 并确认结果仍在 base 内(防符号链接绕过),越界返回 false
|
||||
func joinWithinBase(base, sub string) (string, bool) {
|
||||
absPath := filepath.Join(base, filepath.FromSlash(sub))
|
||||
if rel, err := filepath.Rel(base, absPath); err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", false
|
||||
}
|
||||
return absPath, true
|
||||
}
|
||||
|
||||
// getDrawioViewerMIME 返回 viewer 静态资源的 MIME
|
||||
func getDrawioViewerMIME(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".html", ".htm":
|
||||
return "text/html; charset=utf-8"
|
||||
case ".js", ".mjs":
|
||||
return "application/javascript; charset=utf-8"
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".woff":
|
||||
return "font/woff"
|
||||
case ".woff2":
|
||||
return "font/woff2"
|
||||
case ".ttf":
|
||||
return "font/ttf"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// unzipToDir 解压整个 zip 到目标目录(覆盖已存在文件)
|
||||
func unzipToDir(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range r.File {
|
||||
if err := extractZipFile(f, destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZipFile 解压单个 zip 条目到目标目录(带路径遍历防护)
|
||||
func extractZipFile(f *zip.File, destDir string) error {
|
||||
name, ok := sanitizeSubPath(f.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("可疑路径: %s", f.Name)
|
||||
}
|
||||
destPath, ok := joinWithinBase(destDir, name)
|
||||
if !ok {
|
||||
return fmt.Errorf("逃逸路径: %s", f.Name)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
return os.MkdirAll(destPath, 0755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
@@ -50,19 +50,3 @@ func OpenPath(path string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用)
|
||||
func FormatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
@@ -103,43 +103,4 @@ func (l *Logger) log(level LogLevel, levelStr, format string, args ...interface{
|
||||
}
|
||||
}
|
||||
|
||||
// LogOperation 记录操作日志(辅助函数)
|
||||
func LogOperation(operation, path string, success bool, err error) {
|
||||
logger := GetGlobalLogger()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
logger.Info("操作: %s %s - 成功", operation, path)
|
||||
} else {
|
||||
logger.Error("操作: %s %s - 失败: %v", operation, path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError 记录错误日志(辅助函数)
|
||||
func LogError(operation string, path string, err error) {
|
||||
logger := GetGlobalLogger()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("错误: %s %s - %v", operation, path, err)
|
||||
|
||||
// 如果是调试模式,输出堆栈跟踪
|
||||
if os.Getenv("UDESK_DEBUG") == "1" {
|
||||
logger.Debug("堆栈:\n%s", GetStackTrace(2))
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 全局日志记录器(向后兼容)==========
|
||||
|
||||
var (
|
||||
globalLogger *Logger
|
||||
)
|
||||
|
||||
// GetGlobalLogger 获取全局日志记录器
|
||||
func GetGlobalLogger() *Logger {
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"u-desk/internal/common"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -288,7 +288,7 @@ func copyRecursively(src, dst string) error {
|
||||
return copyDirectory(src, dst)
|
||||
}
|
||||
|
||||
return copyFile(src, dst)
|
||||
return common.CopyFile(src, dst)
|
||||
}
|
||||
|
||||
// copyDirectory 复制目录
|
||||
@@ -314,7 +314,7 @@ func copyDirectory(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
if err := common.CopyFile(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -323,36 +323,6 @@ func copyDirectory(src, dst string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile 复制文件
|
||||
func copyFile(src, dst string) error {
|
||||
// 打开源文件
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// 复制内容
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 复制文件权限
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Chmod(dst, srcInfo.Mode())
|
||||
}
|
||||
|
||||
// getDevice 获取文件所在设备(盘符)
|
||||
func getDevice(path string) string {
|
||||
absPath, err := filepath.Abs(path)
|
||||
@@ -377,8 +347,8 @@ func generateRandomString(length int) string {
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
// 如果加密随机数生成失败,回退到时间戳(极低概率)
|
||||
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||
// crypto/rand 失败时用时间戳+索引偏移避免碰撞
|
||||
b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))]
|
||||
continue
|
||||
}
|
||||
b[i] = charset[n.Int64()]
|
||||
|
||||
+206
-30
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -143,11 +145,10 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
}
|
||||
|
||||
s.logRead(path, int64(len(data)), nil)
|
||||
return BytesToString(data), nil
|
||||
return common.BytesToString(data), nil
|
||||
}
|
||||
|
||||
// Write 写入文件内容(实现 FileService 接口)
|
||||
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
||||
// writeFileWithLog 内部写入实现(路径验证+大小检查+写入+日志)
|
||||
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
@@ -229,28 +230,20 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
var deleteErr error
|
||||
if info.IsDir() {
|
||||
deleteErr = os.RemoveAll(path)
|
||||
} else {
|
||||
deleteErr = os.Remove(path)
|
||||
}
|
||||
|
||||
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
|
||||
|
||||
if deleteErr != nil {
|
||||
return nil, fmt.Errorf("删除失败: %v", deleteErr)
|
||||
}
|
||||
|
||||
// 如果启用回收站,移动到回收站而非永久删除
|
||||
if s.recycleBin != nil {
|
||||
// 检查是否已在回收站中
|
||||
if !isInRecycleBin(path) {
|
||||
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
|
||||
// 回收站失败,记录但继续
|
||||
slog.Warn("移动到回收站失败", "error", err)
|
||||
// 回收站优先:先尝试移动,失败再永久删除
|
||||
if s.recycleBin != nil && !isInRecycleBin(path) {
|
||||
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
|
||||
slog.Warn("回收站移动失败,改为永久删除", "error", err)
|
||||
if err := s.permanentDelete(path, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
s.logDelete(path, info.IsDir(), info.Size(), nil)
|
||||
}
|
||||
} else {
|
||||
// 无回收站或已在回收站中,永久删除
|
||||
if err := s.permanentDelete(path, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +252,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: FormatBytes(info.Size()),
|
||||
SizeStr: common.FormatBytesInt(info.Size()),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
@@ -366,7 +359,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: FormatBytes(info.Size()),
|
||||
SizeStr: common.FormatBytesInt(info.Size()),
|
||||
IsDir: true,
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
@@ -414,7 +407,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: FormatBytes(info.Size()),
|
||||
SizeStr: common.FormatBytesInt(info.Size()),
|
||||
IsDir: false,
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
@@ -439,7 +432,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
|
||||
"name": info.Name(),
|
||||
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
||||
"size": info.Size(),
|
||||
"size_str": FormatBytes(info.Size()),
|
||||
"size_str": common.FormatBytesInt(info.Size()),
|
||||
"is_dir": info.IsDir(),
|
||||
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
"mode": info.Mode().String(),
|
||||
@@ -495,7 +488,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
||||
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: FormatBytes(info.Size()),
|
||||
SizeStr: common.FormatBytesInt(info.Size()),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
@@ -503,6 +496,174 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========== 复制与移动 ==========
|
||||
|
||||
// checkCopyMovePaths 复制/移动共用的前置校验:路径合法、源存在、目标不存在
|
||||
func (s *FileSystemService) checkCopyMovePaths(src, dst string) (os.FileInfo, error) {
|
||||
if err := s.validatePath(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validatePath(dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("源路径不存在: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return nil, fmt.Errorf("目标已存在: %s", dst)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// logCopyMoveAudit 记录复制/移动成功审计日志
|
||||
func (s *FileSystemService) logCopyMoveAudit(op AuditOperation, src, dst string, isDir bool) {
|
||||
s.logAudit(AuditLogEntry{
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
Operation: op,
|
||||
Path: dst,
|
||||
OldPath: src,
|
||||
IsDirectory: isDir,
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
// CopyPath 递归复制文件或目录到目标路径(dst 必须不存在,唯一性由调用方保证)
|
||||
// onProgress 每读取一块回调一次(可为 nil),copied/total 单位字节
|
||||
func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, total int64)) error {
|
||||
info, err := s.checkCopyMovePaths(src, dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := pathSize(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("统计源大小失败: %v", err)
|
||||
}
|
||||
|
||||
var copied int64
|
||||
if err := copyPathRecursive(src, dst, info, &copied, total, onProgress); err != nil {
|
||||
return fmt.Errorf("复制失败: %w", err)
|
||||
}
|
||||
|
||||
s.logCopyMoveAudit(OperationCopy, src, dst, info.IsDir())
|
||||
return nil
|
||||
}
|
||||
|
||||
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
|
||||
func (s *FileSystemService) MovePath(src, dst string) error {
|
||||
info, err := s.checkCopyMovePaths(src, dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 快路径:同盘 rename(不发进度)
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
|
||||
return nil
|
||||
}
|
||||
|
||||
// 跨盘:复制 + 删除源
|
||||
if err := s.CopyPath(src, dst, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(src); err != nil {
|
||||
return fmt.Errorf("移动后删除源失败: %v", err)
|
||||
}
|
||||
|
||||
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
|
||||
return nil
|
||||
}
|
||||
|
||||
// UniquePath 返回不冲突的目标路径:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)
|
||||
// 命名循环复用 common.UniqueName(与 SFTP 远端重名逻辑同源)
|
||||
func UniquePath(path string) string {
|
||||
dir, name := filepath.Split(path)
|
||||
unique, err := common.UniqueName(name, func(candidate string) (bool, error) {
|
||||
_, err := os.Lstat(filepath.Join(dir, candidate))
|
||||
return err == nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return path // exists 恒不返回错误,防御
|
||||
}
|
||||
return filepath.Join(dir, unique)
|
||||
}
|
||||
|
||||
// copyPathRecursive 递归复制单个路径(文件/目录/符号链接)
|
||||
func copyPathRecursive(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
|
||||
switch {
|
||||
case info.IsDir():
|
||||
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyPathRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), entryInfo, copied, total, onProgress); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, dst)
|
||||
default:
|
||||
return copyFileWithProgress(src, dst, info, copied, total, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// copyFileWithProgress 复制单个普通文件,按读取量累计回调进度
|
||||
func copyFileWithProgress(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
var reader io.Reader = in
|
||||
if onProgress != nil {
|
||||
reader = &common.CountingReader{R: in, OnN: func(n int64) {
|
||||
*copied += n
|
||||
onProgress(*copied, total)
|
||||
}}
|
||||
}
|
||||
_, err = io.Copy(out, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// pathSize 统计路径总大小(文件为自身大小,目录递归求和,跳过符号链接)
|
||||
func pathSize(path string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
if info, e := d.Info(); e == nil {
|
||||
total += info.Size()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ========== ZIP操作接口 ==========
|
||||
|
||||
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
||||
@@ -532,12 +693,27 @@ func getCurrentTimestamp() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// permanentDelete 永久删除路径(目录 RemoveAll / 文件 Remove)并记录删除日志
|
||||
func (s *FileSystemService) permanentDelete(path string, info os.FileInfo) error {
|
||||
var deleteErr error
|
||||
if info.IsDir() {
|
||||
deleteErr = os.RemoveAll(path)
|
||||
} else {
|
||||
deleteErr = os.Remove(path)
|
||||
}
|
||||
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("删除失败: %v", deleteErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isInRecycleBin 检查路径是否在回收站中
|
||||
func isInRecycleBin(path string) bool {
|
||||
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
||||
cleanPath := filepath.Clean(path)
|
||||
cleanBinPath := filepath.Clean(recycleBinPath)
|
||||
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
|
||||
return strings.HasPrefix(cleanPath, cleanBinPath)
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
Reference in New Issue
Block a user