2026-05-13 16:07:25 +08:00

245 lines
6.7 KiB
Go

package upload
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"unicode"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/platform/idgen"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
const (
uploadSniffBytes = 512
)
var (
imageUploadPolicy = uploadPolicy{
kind: "images",
requireImage: true,
allowedExtensions: map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".svga": {}, ".pag": {},
},
}
fileUploadPolicy = uploadPolicy{
kind: "files",
}
)
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type Handler struct {
audit shared.OperationLogger
objectPrefix string
uploader ObjectUploader
}
func New(uploader ObjectUploader, objectPrefix string, audit shared.OperationLogger) *Handler {
objectPrefix = strings.Trim(strings.TrimSpace(objectPrefix), "/")
if objectPrefix == "" {
objectPrefix = "admin"
}
return &Handler{audit: audit, objectPrefix: objectPrefix, uploader: uploader}
}
func (h *Handler) UploadFile(c *gin.Context) {
h.uploadObject(c, fileUploadPolicy)
}
func (h *Handler) UploadImage(c *gin.Context) {
h.uploadObject(c, imageUploadPolicy)
}
func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
if h.uploader == nil {
response.ServerError(c, "文件上传未配置")
return
}
file, header, ok := parseUploadFile(c)
if !ok {
return
}
defer file.Close()
defer func() {
if c.Request.MultipartForm != nil {
_ = c.Request.MultipartForm.RemoveAll()
}
}()
if header.Size <= 0 {
response.BadRequest(c, "文件大小不正确")
return
}
head, contentType, detectedContentType, ok := sniffUploadContent(c, file, header)
if !ok {
return
}
if policy.requireImage {
contentType = resolveImageUploadContentType(header.Filename, contentType, detectedContentType)
}
if !validateUploadContent(policy, header.Filename, contentType) {
response.BadRequest(c, "文件类型不支持")
return
}
actor := shared.ActorFromContext(c)
objectKey := buildUploadObjectKey(h.objectPrefix, policy, actor.UserID, header.Filename, contentType, time.Now())
objectURL, err := h.uploader.PutObject(c.Request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
response.ServerError(c, "文件上传失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "upload-file", "cos_objects", objectKey, "success",
fmt.Sprintf("kind=%s size=%d content_type=%s", policy.kind, header.Size, contentType))
response.OK(c, uploadResult{
URL: objectURL,
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: header.Size,
})
}
func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) {
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
response.BadRequest(c, "文件参数不正确")
return nil, nil, false
}
for _, field := range []string{"file", "image", "avatar"} {
file, header, err := c.Request.FormFile(field)
if err == nil {
return file, header, true
}
}
response.BadRequest(c, "文件参数不正确")
return nil, nil, false
}
func sniffUploadContent(c *gin.Context, file multipart.File, header *multipart.FileHeader) ([]byte, string, string, bool) {
head := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(file, head)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
response.BadRequest(c, "文件参数不正确")
return nil, "", "", false
}
head = head[:n]
detectedContentType := http.DetectContentType(head)
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
if contentType == "" || contentType == "application/octet-stream" {
contentType = detectedContentType
}
return head, contentType, detectedContentType, true
}
func validateUploadContent(policy uploadPolicy, filename string, contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if policy.requireImage && !isImageContentType(contentType) {
return false
}
if len(policy.allowedExtensions) == 0 {
return true
}
extension := normalizedUploadExtension(filename, contentType)
_, ok := policy.allowedExtensions[extension]
return ok
}
func resolveImageUploadContentType(filename string, contentType string, detectedContentType string) string {
extension := cleanUploadExtension(path.Ext(filename))
switch extension {
case ".svga":
return "application/x-svga"
case ".pag":
return "application/vnd.tencent.pag"
}
contentType = strings.ToLower(strings.TrimSpace(contentType))
if contentType == "application/x-svga" || contentType == "application/vnd.tencent.pag" {
return contentType
}
if isRasterImageContentType(detectedContentType) {
return strings.ToLower(strings.TrimSpace(detectedContentType))
}
return strings.ToLower(strings.TrimSpace(detectedContentType))
}
func isImageContentType(contentType string) bool {
if isRasterImageContentType(contentType) {
return true
}
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "application/x-svga", "application/vnd.tencent.pag":
return true
default:
return false
}
}
func isRasterImageContentType(contentType string) bool {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg", "image/png", "image/webp":
return true
default:
return false
}
}
func buildUploadObjectKey(objectPrefix string, policy uploadPolicy, actorID uint, filename string, contentType string, now time.Time) string {
date := now.UTC().Format("20060102")
extension := normalizedUploadExtension(filename, contentType)
if extension == "" && policy.requireImage {
extension = ".jpg"
}
idPrefix := strings.TrimSuffix(policy.kind, "s")
return fmt.Sprintf("%s/%s/%d/%s/%s%s", objectPrefix, policy.kind, actorID, date, idgen.New(idPrefix), extension)
}
func normalizedUploadExtension(filename string, contentType string) string {
if extension := cleanUploadExtension(path.Ext(filename)); extension != "" {
return extension
}
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "application/x-svga":
return ".svga"
case "application/vnd.tencent.pag":
return ".pag"
case "application/pdf":
return ".pdf"
default:
return ""
}
}
func cleanUploadExtension(extension string) string {
extension = strings.ToLower(strings.TrimSpace(extension))
if len(extension) < 2 || len(extension) > 10 || extension[0] != '.' {
return ""
}
for _, char := range extension[1:] {
if !unicode.IsDigit(char) && (char < 'a' || char > 'z') {
return ""
}
}
return extension
}