226 lines
6.9 KiB
Go
226 lines
6.9 KiB
Go
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
|
||
}
|