218 lines
5.3 KiB
Go
218 lines
5.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
// uploadDir 上传文件保存目录
|
|
uploadDir = "./static/uploads"
|
|
// uploadURLPrefix 上传文件对外访问的 URL 前缀
|
|
uploadURLPrefix = "/static/uploads/"
|
|
// maxUploadSize 单个文件最大大小(5MB)
|
|
maxUploadSize = 5 << 20
|
|
)
|
|
|
|
// allowedImageExts 允许上传的图片扩展名
|
|
var allowedImageExts = map[string]bool{
|
|
".jpg": true,
|
|
".jpeg": true,
|
|
".png": true,
|
|
".gif": true,
|
|
".webp": true,
|
|
}
|
|
|
|
// allowedImageMIMEs 允许上传的图片真实 MIME 类型
|
|
var allowedImageMIMEs = map[string]bool{
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
}
|
|
|
|
// UploadedFile 上传文件信息
|
|
type UploadedFile struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Size int64 `json:"size"`
|
|
ModTime string `json:"mod_time"`
|
|
}
|
|
|
|
// sanitizeBaseName 清理文件名(去除扩展名和不安全字符)
|
|
func sanitizeBaseName(filename string) string {
|
|
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
|
|
base = strings.ReplaceAll(base, " ", "-")
|
|
|
|
var b strings.Builder
|
|
for _, r := range base {
|
|
if r == '-' || r == '_' ||
|
|
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
res := b.String()
|
|
if res == "" {
|
|
res = "image"
|
|
}
|
|
if len(res) > 40 {
|
|
res = res[:40]
|
|
}
|
|
return res
|
|
}
|
|
|
|
// UploadImage 上传图片(校验格式与大小)
|
|
func UploadImage(c *gin.Context) {
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
|
|
return
|
|
}
|
|
|
|
// 校验文件大小
|
|
if file.Size > maxUploadSize {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 校验扩展名
|
|
ext := strings.ToLower(filepath.Ext(file.Filename))
|
|
if !allowedImageExts[ext] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"})
|
|
return
|
|
}
|
|
|
|
// 打开文件并检测真实 MIME 类型(防止伪造扩展名)
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
|
|
return
|
|
}
|
|
defer src.Close()
|
|
|
|
head := make([]byte, 512)
|
|
n, _ := src.Read(head)
|
|
contentType := http.DetectContentType(head[:n])
|
|
if !allowedImageMIMEs[contentType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"})
|
|
return
|
|
}
|
|
|
|
// 重置读取位置,准备写入
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
|
|
return
|
|
}
|
|
|
|
// 确保上传目录存在
|
|
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
|
|
return
|
|
}
|
|
|
|
// 生成唯一文件名:时间戳_原始名.扩展名
|
|
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext)
|
|
dstPath := filepath.Join(uploadDir, filename)
|
|
|
|
dst, err := os.Create(dstPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
|
return
|
|
}
|
|
defer dst.Close()
|
|
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"url": uploadURLPrefix + filename,
|
|
"name": filename,
|
|
"size": file.Size,
|
|
})
|
|
}
|
|
|
|
// ListUploads 列出已上传的图片
|
|
func ListUploads(c *gin.Context) {
|
|
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(uploadDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
|
return
|
|
}
|
|
|
|
files := make([]UploadedFile, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if !allowedImageExts[ext] {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, UploadedFile{
|
|
Name: e.Name(),
|
|
URL: uploadURLPrefix + e.Name(),
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
// 按修改时间倒序(最新的在前)
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].ModTime > files[j].ModTime
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": files})
|
|
}
|
|
|
|
// DeleteUpload 删除已上传的图片
|
|
func DeleteUpload(c *gin.Context) {
|
|
name := c.Param("name")
|
|
|
|
// 防止路径穿越
|
|
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"})
|
|
return
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if !allowedImageExts[ext] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"})
|
|
return
|
|
}
|
|
|
|
path := filepath.Join(uploadDir, name)
|
|
if _, err := os.Stat(path); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
|
return
|
|
}
|
|
|
|
if err := os.Remove(path); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
|
}
|