2026-05-12 09:53:20 +08:00

226 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package http
import (
"bytes"
"fmt"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"unicode"
"hyapp/pkg/idgen"
"hyapp/services/gateway-service/internal/auth"
)
const (
// uploadMultipartOverheadLimit 给 multipart 边界、字段头和少量表单字段预留空间。
uploadMultipartOverheadLimit = 1 << 20
// avatarUploadMaxBytes 限制头像体积,避免注册入口被大文件拖垮。
avatarUploadMaxBytes int64 = 5 << 20
// fileUploadMaxBytes 是 App 通用文件入口的单文件上限。
fileUploadMaxBytes int64 = 20 << 20
// uploadSniffBytes 是 net/http 内容嗅探算法使用的最大字节数。
uploadSniffBytes = 512
)
type uploadPolicy struct {
Kind string
MaxBytes int64
RequireImage bool
AllowedExtensions map[string]struct{}
}
var (
avatarUploadPolicy = uploadPolicy{
Kind: "avatars",
MaxBytes: avatarUploadMaxBytes,
RequireImage: true,
AllowedExtensions: map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {},
},
}
fileUploadPolicy = uploadPolicy{
Kind: "files",
MaxBytes: fileUploadMaxBytes,
}
)
type uploadResponse struct {
URL string `json:"url"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
// uploadAvatar 上传用户头像文件,只接受常见静态图片格式。
func (h *Handler) uploadAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, avatarUploadPolicy)
}
// uploadFile 上传 App 通用文件,不绑定用户资料更新语义。
func (h *Handler) uploadFile(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, fileUploadPolicy)
}
func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request, policy uploadPolicy) {
if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if h.objectUploader == nil {
// 上传入口必须 fail-closed避免客户端拿到伪造 URL 后继续写用户资料。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
file, header, ok := parseUploadFile(writer, request, policy.MaxBytes)
if !ok {
return
}
defer file.Close()
defer func() {
if request.MultipartForm != nil {
_ = request.MultipartForm.RemoveAll()
}
}()
if header.Size <= 0 || header.Size > policy.MaxBytes {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
head, contentType, detectedContentType, ok := sniffUploadContent(writer, request, file, header)
if !ok {
return
}
if policy.RequireImage {
// 头像用文件头嗅探结果做安全判断,不信任客户端传入的 multipart Content-Type。
contentType = detectedContentType
}
if !validateUploadContent(policy, header.Filename, contentType) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, timeNow())
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, uploadResponse{
URL: objectURL,
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: header.Size,
})
}
func parseUploadFile(writer http.ResponseWriter, request *http.Request, maxBytes int64) (multipart.File, *multipart.FileHeader, bool) {
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes+uploadMultipartOverheadLimit)
if err := request.ParseMultipartForm(1 << 20); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, nil, false
}
for _, field := range []string{"file", "avatar"} {
file, header, err := request.FormFile(field)
if err == nil {
return file, header, true
}
}
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, nil, false
}
func sniffUploadContent(writer http.ResponseWriter, request *http.Request, 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 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
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 && !isAvatarContentType(contentType) {
return false
}
if len(policy.AllowedExtensions) == 0 {
return true
}
extension := normalizedUploadExtension(filename, contentType)
_, ok := policy.AllowedExtensions[extension]
return ok
}
func isAvatarContentType(contentType string) bool {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg", "image/png", "image/webp":
return true
default:
return false
}
}
func buildUploadObjectKey(policy uploadPolicy, userID int64, 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("app/%s/%d/%s/%s%s", policy.Kind, userID, 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/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
}