245 lines
6.7 KiB
Go
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
|
|
}
|