package appapi 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, time.Now().UTC()) 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 }