350 lines
10 KiB
Go
350 lines
10 KiB
Go
package filesystem
|
||
|
||
import (
|
||
"archive/zip"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ZipFileEntry 表示 zip 文件中的一个文件条目
|
||
type ZipFileEntry struct {
|
||
Name string
|
||
Path string // 在 zip 中的完整路径
|
||
Size int64
|
||
Modified string
|
||
IsDir bool
|
||
Method string // 压缩方法 (Store/Deflate)
|
||
}
|
||
|
||
// validateZipPath 验证 ZIP 文件路径是否有效
|
||
// 统一的路径验证逻辑,避免在多个函数中重复
|
||
func validateZipPath(zipPath string) error {
|
||
if !isSafePath(zipPath) {
|
||
return fmt.Errorf("zip 路径不安全")
|
||
}
|
||
|
||
// 检查 zip 文件是否存在
|
||
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
|
||
return fmt.Errorf("zip 文件不存在")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// debugLog 条件日志记录,仅在调试模式下输出
|
||
// 通过设置环境变量 UDESK_ZIP_DEBUG=1 启用调试日志
|
||
var zipDebugMode = os.Getenv("UDESK_ZIP_DEBUG") == "1"
|
||
|
||
func debugLog(format string, args ...interface{}) {
|
||
if zipDebugMode {
|
||
log.Printf(format, args...)
|
||
}
|
||
}
|
||
|
||
// ListZipContents 列出 zip 文件内容
|
||
// 🔒 安全增强:添加ZIP炸弹防护、路径遍历检查
|
||
func ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||
debugLog("[ListZipContents] 开始处理 ZIP 文件: %s", zipPath)
|
||
|
||
// 统一验证路径
|
||
if err := validateZipPath(zipPath); err != nil {
|
||
debugLog("[ListZipContents] 路径验证失败: %v", err)
|
||
return nil, err
|
||
}
|
||
|
||
// 检查文件是否存在
|
||
fileInfo, err := os.Stat(zipPath)
|
||
if err != nil {
|
||
debugLog("[ListZipContents] 文件状态检查失败: %v", err)
|
||
return nil, fmt.Errorf("无法访问文件: %v", err)
|
||
}
|
||
|
||
debugLog("[ListZipContents] 文件信息: 大小=%d bytes, 权限=%v", fileInfo.Size(), fileInfo.Mode())
|
||
|
||
// 🔒 安全检查:检查文件大小(太小或太大)
|
||
if fileInfo.Size() < 22 {
|
||
debugLog("[ListZipContents] 文件太小,可能不是有效的 ZIP 文件: %d bytes", fileInfo.Size())
|
||
return nil, fmt.Errorf("文件太小 (%d bytes),可能不是有效的 ZIP 文件", fileInfo.Size())
|
||
}
|
||
|
||
// 🔒 安全检查:ZIP炸弹防护(检查文件大小)
|
||
if fileInfo.Size() > MaxZipSize {
|
||
debugLog("[ListZipContents] ZIP文件过大: %d bytes", fileInfo.Size())
|
||
return nil, fmt.Errorf("ZIP文件过大 (%d bytes),超过限制 (%d bytes)", fileInfo.Size(), MaxZipSize)
|
||
}
|
||
|
||
// 检查文件是否可读
|
||
file, err := os.Open(zipPath)
|
||
if err != nil {
|
||
debugLog("[ListZipContents] 无法打开文件: %v", err)
|
||
return nil, fmt.Errorf("无法打开文件: %v", err)
|
||
}
|
||
|
||
// 读取前 4 个字节检查 ZIP 文件头
|
||
header := make([]byte, 4)
|
||
n, err := file.Read(header)
|
||
file.Close()
|
||
if err != nil || n != 4 {
|
||
debugLog("[ListZipContents] 无法读取文件头: n=%d, err=%v", n, err)
|
||
return nil, fmt.Errorf("无法读取文件头")
|
||
}
|
||
|
||
debugLog("[ListZipContents] 文件头: 0x%02x 0x%02x 0x%02x 0x%02x", header[0], header[1], header[2], header[3])
|
||
|
||
// ZIP 文件应该是 PK\x03\x04 或 PK\x05\x06 (空 ZIP)
|
||
if header[0] != 0x50 || header[1] != 0x4B { // 'P' 'K'
|
||
debugLog("[ListZipContents] 文件头签名错误,不是有效的 ZIP 文件")
|
||
return nil, fmt.Errorf("文件头签名错误,不是有效的 ZIP 文件 (可能是其他格式或已损坏)")
|
||
}
|
||
|
||
// 打开 zip 文件
|
||
debugLog("[ListZipContents] 尝试打开 ZIP 读取器...")
|
||
reader, err := zip.OpenReader(zipPath)
|
||
if err != nil {
|
||
debugLog("[ListZipContents] 打开 ZIP 失败: %v", err)
|
||
debugLog("[ListZipContents] 错误类型: %T", err)
|
||
|
||
// 提供更详细的错误信息和解决建议
|
||
errMsg := fmt.Sprintf("打开 zip 文件失败: %v", err)
|
||
if strings.Contains(err.Error(), "not a valid zip file") {
|
||
errMsg += "\n\n可能的原因:\n" +
|
||
"1. 文件已损坏或不完整\n" +
|
||
"2. 不是标准的 ZIP 格式\n" +
|
||
"3. 文件正在被其他程序占用(如压缩软件)\n" +
|
||
"4. 使用了特殊的压缩方式\n\n" +
|
||
"建议解决方法:\n" +
|
||
"- 关闭所有可能打开该文件的程序\n" +
|
||
"- 尝试用 7-Zip 或 WinRAR 重新压缩\n" +
|
||
"- 检查文件大小是否正常(不是 0 字节)\n" +
|
||
"- 如果是从网络下载的,尝试重新下载"
|
||
}
|
||
return nil, fmt.Errorf("%s", errMsg)
|
||
}
|
||
defer reader.Close()
|
||
|
||
debugLog("[ListZipContents] 成功打开 ZIP,开始读取文件列表...")
|
||
|
||
// 🔒 安全检查:ZIP炸弹防护(检查解压后总大小)
|
||
var totalUncompressed int64
|
||
for _, file := range reader.File {
|
||
totalUncompressed += int64(file.UncompressedSize64)
|
||
}
|
||
if totalUncompressed > MaxExtractSize {
|
||
debugLog("[ListZipContents] 解压后总大小过大: %d bytes", totalUncompressed)
|
||
return nil, fmt.Errorf("解压后总大小过大 (%d bytes),超过限制 (%d bytes)", totalUncompressed, MaxExtractSize)
|
||
}
|
||
|
||
var result []map[string]interface{}
|
||
fileCount := 0
|
||
dirCount := 0
|
||
|
||
// 遍历 zip 文件中的所有文件
|
||
for _, file := range reader.File {
|
||
// 跳过 macOS 资源分支文件
|
||
if strings.HasPrefix(file.Name, "__MACOSX/") {
|
||
continue
|
||
}
|
||
|
||
// 🔒 安全检查:路径遍历攻击防护
|
||
if strings.Contains(file.Name, "..") {
|
||
debugLog("[ListZipContents] 检测到路径遍历尝试: %s", file.Name)
|
||
return nil, fmt.Errorf("ZIP文件包含不安全的路径: %s", file.Name)
|
||
}
|
||
|
||
// 🔒 安全检查:绝对路径防护
|
||
if filepath.IsAbs(file.Name) {
|
||
debugLog("[ListZipContents] 检测到绝对路径: %s", file.Name)
|
||
return nil, fmt.Errorf("ZIP文件包含绝对路径: %s", file.Name)
|
||
}
|
||
|
||
isDir := file.Mode().IsDir()
|
||
name := filepath.Base(file.Name)
|
||
|
||
// 对于目录,使用目录名;对于文件,使用文件名
|
||
if isDir {
|
||
name = file.Name
|
||
dirCount++
|
||
} else {
|
||
fileCount++
|
||
}
|
||
|
||
// 压缩方法描述
|
||
method := "Store"
|
||
if file.Method == 8 {
|
||
method = "Deflate"
|
||
}
|
||
|
||
entry := map[string]interface{}{
|
||
"name": name,
|
||
"path": file.Name, // zip 中的完整路径(已使用 /)
|
||
"is_dir": isDir,
|
||
"size": file.UncompressedSize64,
|
||
"compressed": file.CompressedSize64,
|
||
"mod_time": file.Modified.Format("2006-01-02 15:04:05"),
|
||
"method": method,
|
||
}
|
||
|
||
result = append(result, entry)
|
||
}
|
||
|
||
debugLog("[ListZipContents] 读取完成: %d 个文件, %d 个目录", fileCount, dirCount)
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
|
||
// 优化:使用通用包装器,消除重复代码
|
||
func ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
||
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
|
||
// 打开文件
|
||
rc, err := file.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
|
||
}
|
||
defer rc.Close()
|
||
|
||
// 读取内容
|
||
data, err := readAllFromFile(rc)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取文件内容失败: %v", err)
|
||
}
|
||
|
||
return string(data), nil
|
||
})
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return result.(string), nil
|
||
}
|
||
|
||
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
|
||
// 返回临时文件的完整路径
|
||
// 适用于提取图片等二进制文件
|
||
// 优化:使用通用包装器,消除重复代码
|
||
func ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||
debugLog("[ExtractFileFromZipToTemp] 开始提取: %s from %s", filePath, zipPath)
|
||
|
||
// 启动临时文件清理协程
|
||
go CleanOldTempFiles()
|
||
|
||
// 创建临时目录
|
||
tempDir := filepath.Join(os.TempDir(), TempFileDir)
|
||
if err := os.MkdirAll(tempDir, DefaultDirPermissions); err != nil {
|
||
return "", fmt.Errorf("创建临时目录失败: %v", err)
|
||
}
|
||
|
||
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
|
||
// 安全检查:文件大小限制
|
||
if file.UncompressedSize64 > MaxSingleFileSize {
|
||
debugLog("[ExtractFileFromZipToTemp] 文件过大: %d bytes", file.UncompressedSize64)
|
||
return nil, fmt.Errorf("文件过大 (%d bytes),超过限制 (%d bytes)",
|
||
file.UncompressedSize64, MaxSingleFileSize)
|
||
}
|
||
|
||
// 打开文件
|
||
rc, err := file.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
|
||
}
|
||
defer rc.Close()
|
||
|
||
// 生成临时文件名
|
||
tempFileName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(file.Name))
|
||
tempFilePath := filepath.Join(tempDir, tempFileName)
|
||
|
||
// 创建临时文件
|
||
outFile, err := os.Create(tempFilePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建临时文件失败: %v", err)
|
||
}
|
||
defer outFile.Close()
|
||
|
||
// 限制写入大小
|
||
limitedReader := &io.LimitedReader{R: rc, N: MaxSingleFileSize}
|
||
written, err := io.Copy(outFile, limitedReader)
|
||
if err != nil {
|
||
os.Remove(tempFilePath)
|
||
return nil, fmt.Errorf("写入临时文件失败: %v", err)
|
||
}
|
||
|
||
// 检查是否超过限制
|
||
if limitedReader.N <= 0 {
|
||
os.Remove(tempFilePath)
|
||
return nil, fmt.Errorf("文件大小超过限制")
|
||
}
|
||
|
||
debugLog("[ExtractFileFromZipToTemp] 提取成功: %s -> %s (%d bytes)",
|
||
file.Name, tempFilePath, written)
|
||
return tempFilePath, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return result.(string), nil
|
||
}
|
||
|
||
// CleanOldTempFiles 清理超过指定时间的临时文件
|
||
// 🔒 新增:防止临时文件累积占用磁盘空间
|
||
func CleanOldTempFiles() {
|
||
tempDir := filepath.Join(os.TempDir(), "u-desk-zip")
|
||
|
||
// 检查临时目录是否存在
|
||
dir, err := os.Open(tempDir)
|
||
if err != nil {
|
||
// 目录不存在或其他错误,无需清理
|
||
return
|
||
}
|
||
defer dir.Close()
|
||
|
||
// 读取目录内容
|
||
files, err := dir.Readdir(-1)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
cleanedCount := 0
|
||
now := time.Now()
|
||
|
||
for _, file := range files {
|
||
// 跳过目录
|
||
if file.IsDir() {
|
||
continue
|
||
}
|
||
|
||
// 检查文件年龄
|
||
if now.Sub(file.ModTime()) > TempFileCleanupAge {
|
||
filePath := filepath.Join(tempDir, file.Name())
|
||
if err := os.Remove(filePath); err == nil {
|
||
cleanedCount++
|
||
debugLog("[CleanOldTempFiles] 清理临时文件: %s (年龄: %v)", file.Name(), now.Sub(file.ModTime()))
|
||
}
|
||
}
|
||
}
|
||
|
||
if cleanedCount > 0 {
|
||
debugLog("[CleanOldTempFiles] 清理完成: 共清理 %d 个临时文件", cleanedCount)
|
||
}
|
||
}
|
||
|
||
// GetZipFileInfo 获取 zip 文件中特定文件的信息
|
||
// 优化:使用通用包装器,消除重复代码
|
||
func GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
|
||
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
|
||
return createFileInfoMap(file, true), nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return result.(map[string]interface{}), nil
|
||
}
|
||
|
||
|