Go+Gin实现安全多文件上传:带MD5校验的完整解决方案

后端

bash 复制代码
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

// 前端传来的文件元数据
type FileMetaRequest struct {
	FileName     string `json:"fileName" binding:"required"`
	FileSize     int64  `json:"fileSize" binding:"required"`
	FileType     string `json:"fileType" binding:"required"`
	FileMD5      string `json:"fileMD5" binding:"required"`
}

// 返回给前端的响应结构
type UploadResponse struct {
	OriginalName string `json:"originalName"`
	SavedPath    string `json:"savedPath"`
	ReceivedMD5  string `json:"receivedMD5"`
	IsVerified   bool   `json:"isVerified"` // 是否通过验证
}

func main() {
	r := gin.Default()

	// 配置CORS
	r.Use(cors.New(cors.Config{
		AllowOrigins: []string{"*"},
		AllowMethods: []string{"POST"},
	}))

	// 上传目录
	uploadDir := "uploads"
	if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
		os.Mkdir(uploadDir, 0755)
	}

	r.POST("/upload", func(c *gin.Context) {
		// 1. 获取元数据JSON
		metaJson := c.PostForm("metadata")
		var fileMetas []FileMetaRequest
		if err := json.Unmarshal([]byte(metaJson), &fileMetas); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "元数据解析失败"})
			return
		}

		// 2. 获取文件
		form, err := c.MultipartForm()
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "文件获取失败"})
			return
		}
		files := form.File["files"]

		// 3. 验证文件数量匹配
		if len(files) != len(fileMetas) {
			c.JSON(http.StatusBadRequest, gin.H{
				"error": fmt.Sprintf("元数据与文件数量不匹配(元数据:%d 文件:%d)",
					len(fileMetas), len(files)),
			})
			return
		}

		var results []UploadResponse
		for i, file := range files {
			meta := fileMetas[i]

			// 4. 验证基本元数据
			if file.Filename != meta.FileName ||
				file.Size != meta.FileSize {
				results = append(results, UploadResponse{
					OriginalName: file.Filename,
					IsVerified:   false,
				})
				continue
			}

			// 5. 保存文件
			savedName := fmt.Sprintf("%s%s", meta.FileMD5, filepath.Ext(file.Filename))
			savePath := filepath.Join(uploadDir, savedName)

			if err := c.SaveUploadedFile(file, savePath); err != nil {
				results = append(results, UploadResponse{
					OriginalName: file.Filename,
					IsVerified:   false,
				})
				continue
			}

			// 6. 记录结果(实际项目中这里应该做MD5校验)
			results = append(results, UploadResponse{
				OriginalName: file.Filename,
				SavedPath:    savePath,
				ReceivedMD5:  meta.FileMD5,
				IsVerified:   true,
			})
		}

		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"results": results,
		})
	})

	log.Println("服务启动在 :8080")
	r.Run(":8080")
}

前端

bash 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件上传系统</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js"></script>

    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        h1 {
            color: #2c3e50;
            text-align: center;
            margin-bottom: 30px;
        }
        .upload-container {
            background-color: white;
            padding: 25px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .file-drop-area {
            border: 2px dashed #3498db;
            border-radius: 5px;
            padding: 30px;
            text-align: center;
            margin-bottom: 20px;
            transition: all 0.3s;
        }
        .file-drop-area.highlight {
            background-color: #f0f8ff;
            border-color: #2980b9;
        }
        #fileInput {
            display: none;
        }
        .file-label {
            display: inline-block;
            padding: 10px 20px;
            background-color: #3498db;
            color: white;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        .file-label:hover {
            background-color: #2980b9;
        }
        .file-list {
            margin-top: 20px;
        }
        .file-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px;
            border-bottom: 1px solid #eee;
        }
        .file-info {
            flex: 1;
        }
        .file-name {
            font-weight: bold;
        }
        .file-meta {
            font-size: 0.8em;
            color: #7f8c8d;
        }
        .file-type {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 0.8em;
            margin-left: 10px;
        }
        .type-body {
            background-color: #2ecc71;
            color: white;
        }
        .type-attachment {
            background-color: #e74c3c;
            color: white;
        }
        .progress-container {
            margin-top: 20px;
        }
        .progress-bar {
            height: 20px;
            background-color: #ecf0f1;
            border-radius: 4px;
            margin-bottom: 10px;
            overflow: hidden;
        }
        .progress {
            height: 100%;
            background-color: #3498db;
            width: 0%;
            transition: width 0.3s;
        }
        .results {
            margin-top: 30px;
        }
        .result-item {
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 4px;
            background-color: #f8f9fa;
        }
        .success {
            border-left: 4px solid #2ecc71;
        }
        .error {
            border-left: 4px solid #e74c3c;
        }
        button {
            padding: 10px 20px;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s;
        }
        button:hover {
            background-color: #2980b9;
        }
        button:disabled {
            background-color: #95a5a6;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
<h1>邮件文件上传系统</h1>

<div class="upload-container">
    <div class="file-drop-area" id="dropArea">
        <input type="file" id="fileInput" multiple>
        <label for="fileInput" class="file-label">选择文件或拖放到此处</label>
        <p>支持多文件上传,自动计算MD5校验值</p>
    </div>

    <div class="file-list" id="fileList"></div>

    <div class="progress-container" id="progressContainer" style="display: none;">
        <h3>上传进度</h3>
        <div class="progress-bar">
            <div class="progress" id="progressBar"></div>
        </div>
        <div id="progressText">准备上传...</div>
    </div>

    <button id="uploadBtn" disabled>开始上传</button>
    <button id="clearBtn">清空列表</button>
</div>

<div class="results" id="results"></div>

<script>
    // 全局变量
    let files = [];
    const dropArea = document.getElementById('dropArea');
    const fileInput = document.getElementById('fileInput');
    const fileList = document.getElementById('fileList');
    const uploadBtn = document.getElementById('uploadBtn');
    const clearBtn = document.getElementById('clearBtn');
    const progressContainer = document.getElementById('progressContainer');
    const progressBar = document.getElementById('progressBar');
    const progressText = document.getElementById('progressText');
    const resultsContainer = document.getElementById('results');

    // 拖放功能
    dropArea.addEventListener('dragover', (e) => {
        e.preventDefault();
        dropArea.classList.add('highlight');
    });

    dropArea.addEventListener('dragleave', () => {
        dropArea.classList.remove('highlight');
    });

    dropArea.addEventListener('drop', (e) => {
        e.preventDefault();
        dropArea.classList.remove('highlight');
        if (e.dataTransfer.files.length) {
            fileInput.files = e.dataTransfer.files;
            handleFiles();
        }
    });

    // 文件选择处理
    fileInput.addEventListener('change', handleFiles);

    async function handleFiles() {
        const newFiles = Array.from(fileInput.files);
        if (newFiles.length === 0) return;

        // 为每个文件计算MD5并创建元数据
        for (const file of newFiles) {
            const fileMeta = {
                file: file,
                name: file.name,
                size: file.size,
                type: file.type,
                md5: await calculateMD5(file),
            };
            files.push(fileMeta);
        }

        renderFileList();
        uploadBtn.disabled = false;
    }

    // 计算MD5
    async function calculateMD5(file) {
        return new Promise((resolve) => {
            const reader = new FileReader();
            reader.onload = (e) => {
                const hash = md5(e.target.result);
                resolve(hash);
            };
            reader.readAsBinaryString(file); // 注意这里使用 readAsBinaryString
        });
    }

    // 渲染文件列表
    function renderFileList() {
        fileList.innerHTML = '';

        if (files.length === 0) {
            fileList.innerHTML = '<p>没有选择文件</p>';
            uploadBtn.disabled = true;
            return;
        }

        files.forEach((fileMeta, index) => {
            const fileItem = document.createElement('div');
            fileItem.className = 'file-item';

            fileItem.innerHTML = `
                    <div class="file-info">
                        <div class="file-name">${fileMeta.name}</div>
                        <div class="file-meta">
                            大小: ${formatFileSize(fileMeta.size)} |
                            MD5: ${fileMeta.md5.substring(0, 8)}... |
                            类型: ${fileMeta.type || '未知'}
                        </div>
                    </div>
                    <div>
                        <button onclick="toggleFileType(${index})" class="file-type ${fileMeta.isAttachment ? 'type-attachment' : 'type-body'}">
                            ${fileMeta.isAttachment ? '附件' : '正文'}
                        </button>
                    </div>
                `;

            fileList.appendChild(fileItem);
        });
    }

    // 格式化文件大小
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    // 上传文件
    uploadBtn.addEventListener('click', async () => {
        if (files.length === 0) return;

        uploadBtn.disabled = true;
        progressContainer.style.display = 'block';
        resultsContainer.innerHTML = '<h3>上传结果</h3>';

        try {
            const formData = new FormData();

            // 添加元数据
            const metadata = files.map(f => ({
                fileName: f.name,
                fileSize: f.size,
                fileType: f.type,
                fileMD5: f.md5,
            }));
            formData.append('metadata', JSON.stringify(metadata));

            // 添加文件
            files.forEach(f => formData.append('files', f.file));

            // 使用Fetch API上传
            const xhr = new XMLHttpRequest();
            xhr.open('POST', 'http://localhost:8080/upload', true);

            // 进度监听
            xhr.upload.onprogress = (e) => {
                if (e.lengthComputable) {
                    const percent = Math.round((e.loaded / e.total) * 100);
                    progressBar.style.width = percent + '%';
                    progressText.textContent = `上传中: ${percent}% (${formatFileSize(e.loaded)}/${formatFileSize(e.total)})`;
                }
            };

            xhr.onload = () => {
                if (xhr.status === 200) {
                    const response = JSON.parse(xhr.responseText);
                    showResults(response);
                } else {
                    showError('上传失败: ' + xhr.statusText);
                }
            };

            xhr.onerror = () => {
                showError('网络错误,上传失败');
            };

            xhr.send(formData);

        } catch (error) {
            showError('上传出错: ' + error.message);
        }
    });

    // 显示上传结果
    function showResults(response) {
        progressText.textContent = '上传完成!';

        if (response.success) {
            response.results.forEach(result => {
                const resultItem = document.createElement('div');
                resultItem.className = `result-item ${result.isVerified ? 'success' : 'error'}`;

                resultItem.innerHTML = `
                        <div><strong>${result.originalName}</strong></div>
                        <div>保存路径: ${result.savedPath || '无'}</div>
                        <div>MD5校验: ${result.receivedMD5 || '无'} -
                            <span style="color: ${result.isVerified ? '#2ecc71' : '#e74c3c'}">
                                ${result.isVerified ? '✓ 验证通过' : '× 验证失败'}
                            </span>
                        </div>
                    `;

                resultsContainer.appendChild(resultItem);
            });
        } else {
            showError(response.error || '上传失败');
        }
    }

    // 显示错误
    function showError(message) {
        const errorItem = document.createElement('div');
        errorItem.className = 'result-item error';
        errorItem.textContent = message;
        resultsContainer.appendChild(errorItem);
    }

    // 清空列表
    clearBtn.addEventListener('click', () => {
        files = [];
        fileInput.value = '';
        renderFileList();
        progressContainer.style.display = 'none';
        resultsContainer.innerHTML = '';
        uploadBtn.disabled = true;
    });
</script>
</body>
</html>

上传截图

相关推荐
用户962377954482 天前
VulnHub DC-3 靶机渗透测试笔记
安全
叶落阁主3 天前
Tailscale 完全指南:从入门到私有 DERP 部署
运维·安全·远程工作
用户962377954485 天前
DVWA 靶场实验报告 (High Level)
安全
数据智能老司机5 天前
用于进攻性网络安全的智能体 AI——在 n8n 中构建你的第一个 AI 工作流
人工智能·安全·agent
数据智能老司机5 天前
用于进攻性网络安全的智能体 AI——智能体 AI 入门
人工智能·安全·agent
用户962377954485 天前
DVWA 靶场实验报告 (Medium Level)
安全
red1giant_star5 天前
S2-067 漏洞复现:Struts2 S2-067 文件上传路径穿越漏洞
安全
用户962377954485 天前
DVWA Weak Session IDs High 的 Cookie dvwaSession 为什么刷新不出来?
安全
stark张宇6 天前
微服务架构必备:Gin + gRPC + Consul + Nacos + GORM 打造用户服务
微服务·gin·grpc
cipher7 天前
ERC-4626 通胀攻击:DeFi 金库的"捐款陷阱"
前端·后端·安全