261 lines
8.6 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 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, fmt.Sprintf("%d", auth.UserIDFromContext(request.Context())))
}
// uploadRegistrationAvatar 上传注册页临时头像,供尚未拿到后端 session 的三方注册流程使用。
func (h *Handler) uploadRegistrationAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, avatarUploadPolicy, "registration")
}
// uploadFile 上传 App 通用文件,不绑定用户资料更新语义。
func (h *Handler) uploadFile(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, fileUploadPolicy, fmt.Sprintf("%d", auth.UserIDFromContext(request.Context())))
}
func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request, policy uploadPolicy, ownerSegment string) {
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 !avatarExtensionMatchesContentType(header.Filename, detectedContentType) || animatedWebPHeader(head) {
// 注册和旧头像入口只允许静态图片;动态 WebP 的 MIME 与静态相同,必须读取 VP8X 动画位拦截。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
}
if !validateUploadContent(policy, header.Filename, contentType) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
objectKey := buildUploadObjectKey(policy, ownerSegment, 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 avatarExtensionMatchesContentType(filename string, contentType string) bool {
extension := strings.ToLower(path.Ext(filename))
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg":
return extension == ".jpg" || extension == ".jpeg"
case "image/png":
return extension == ".png"
case "image/webp":
return extension == ".webp"
default:
return false
}
}
func animatedWebPHeader(head []byte) bool {
// 有动画的合法 WebP 必须以 VP8X 扩展头声明 ANIMATION flag该字段位于前 21 字节内。
return len(head) >= 21 && string(head[:4]) == "RIFF" && string(head[8:12]) == "WEBP" &&
string(head[12:16]) == "VP8X" && head[20]&0x02 != 0
}
func buildUploadObjectKey(policy uploadPolicy, ownerSegment string, 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")
ownerSegment = strings.Trim(ownerSegment, "/")
if ownerSegment == "" {
// 调用方必须明确文件归属分区;空分区会让不同入口对象混在一起,直接回落到 unknown 便于排查。
ownerSegment = "unknown"
}
return fmt.Sprintf("app/%s/%s/%s/%s%s", policy.Kind, ownerSegment, 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
}