diff --git a/internal/controllers/file_controller.go b/internal/controllers/file_controller.go index ec1ae0d..18cb52b 100644 --- a/internal/controllers/file_controller.go +++ b/internal/controllers/file_controller.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/engigu/baihu-panel/internal/utils" @@ -492,3 +493,50 @@ func (fc *FileController) DownloadFile(c *gin.Context) { c.Header("Content-Type", "application/octet-stream") c.File(fullPath) } + +func (fc *FileController) DownloadZip(c *gin.Context) { + paths := c.QueryArray("path") + if len(paths) == 0 || c.ContentType() == "application/json" { + var req struct { + Paths []string `json:"paths"` + } + if err := c.ShouldBindJSON(&req); err == nil && len(paths) == 0 { + paths = req.Paths + } + } + + if len(paths) == 0 { + utils.BadRequest(c, "path参数必填") + return + } + + validatedAbsPaths := make([]string, 0, len(paths)) + for _, path := range paths { + fullPath, safe := fc.checkPath(path, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if _, err := os.Stat(fullPath); err != nil { + utils.NotFound(c, "文件不存在") + return + } + + validatedAbsPaths = append(validatedAbsPaths, fullPath) + } + + fileName := "baihu-export-" + time.Now().Format("20060102-150405") + ".zip" + if len(validatedAbsPaths) == 1 { + fileName = filepath.Base(validatedAbsPaths[0]) + ".zip" + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", "attachment; filename="+fileName) + c.Header("Content-Type", "application/zip") + + if err := utils.CreateZip(c.Writer, validatedAbsPaths); err != nil { + return + } +} diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index fbcd055..16c25e2 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -129,6 +129,7 @@ func registerFileRoutes(g *gin.RouterGroup, c *Controllers) { files.GET("/tree", c.File.GetFileTree) files.GET("/content", c.File.GetFileContent) files.GET("/download", c.File.DownloadFile) + files.GET("/download-zip", c.File.DownloadZip) files.POST("/content", c.File.SaveFileContent) files.POST("/create", c.File.CreateFile) files.POST("/delete", c.File.DeleteFile) diff --git a/internal/utils/archive.go b/internal/utils/archive.go index ded01bc..0635fa8 100644 --- a/internal/utils/archive.go +++ b/internal/utils/archive.go @@ -5,11 +5,109 @@ import ( "archive/zip" "compress/gzip" "io" + "io/fs" "os" "path/filepath" "strings" ) +func CreateZip(dst io.Writer, basePaths []string) (err error) { + w := zip.NewWriter(dst) + defer func() { + if closeErr := w.Close(); err == nil { + err = closeErr + } + }() + + for _, basePath := range basePaths { + info, err := os.Lstat(basePath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + continue + } + + if info.Mode().IsRegular() { + if err := addZipFile(w, basePath, filepath.Base(basePath), info); err != nil { + return err + } + continue + } + + if info.IsDir() { + baseName := filepath.Base(basePath) + if err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + + rel, err := filepath.Rel(basePath, path) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return nil + } + + name := baseName + if rel != "." { + name = filepath.Join(baseName, rel) + } + name = filepath.ToSlash(name) + + info, err := d.Info() + if err != nil { + return err + } + + if d.IsDir() { + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = name + "/" + _, err = w.CreateHeader(header) + return err + } + + if info.Mode().IsRegular() { + return addZipFile(w, path, name, info) + } + return nil + }); err != nil { + return err + } + } + } + + return nil +} + +func addZipFile(w *zip.Writer, path, name string, info os.FileInfo) error { + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(name) + header.Method = zip.Deflate + + writer, err := w.CreateHeader(header) + if err != nil { + return err + } + + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err +} + func ExtractZip(src, dest string) error { r, err := zip.OpenReader(src) if err != nil { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index f9bf40d..c000f6a 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -192,6 +192,7 @@ export const api = { tree: () => request('/files/tree'), getContent: (path: string) => request<{ path: string; content: string }>(`/files/content?path=${encodeURIComponent(path)}`), download: (path: string) => `${API_BASE_URL}/files/download?path=${encodeURIComponent(path)}`, + downloadZip: (path: string) => `${API_BASE_URL}/files/download-zip?path=${encodeURIComponent(path)}`, saveContent: (path: string, content: string) => request('/files/content', { method: 'POST', body: JSON.stringify({ path, content }) }), create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }), delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }), @@ -725,4 +726,3 @@ export const LOG_STATUS = { FAILED: 'failed' } as const - diff --git a/web/src/components/FileTreeNode.vue b/web/src/components/FileTreeNode.vue index b03d2cb..76c3cf1 100644 --- a/web/src/components/FileTreeNode.vue +++ b/web/src/components/FileTreeNode.vue @@ -1,6 +1,6 @@