Files
platforms/backend/api/internal/logic/upload/avatar.go

172 lines
5.4 KiB
Go
Raw Normal View History

// Package upload 提供受控头像文件的上传、校验与读取能力。
// 版本v1.0.0
package upload
import (
"bytes"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
const (
maxAvatarSize int64 = 2 << 20
maxAvatarDimension = 4096
)
// UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。
func UploadAvatar(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
fileHeader, err := ctx.FormFile("file")
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
file, err := fileHeader.Open()
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
if err != nil || int64(len(content)) > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
extension, contentType, err := validateAvatar(fileHeader.Filename, content)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
datePath := time.Now().Format("2006/01/02")
filename := models.NewIdentity() + extension
directory := filepath.Join(uploadRoot(), "avatars", filepath.FromSlash(datePath))
if err := os.MkdirAll(directory, 0o750); err != nil {
infra.Response.Error(ctx, err)
return
}
targetPath := filepath.Join(directory, filename)
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if _, err = target.Write(content); err != nil {
target.Close()
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
if err = target.Close(); err != nil {
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
uri := "/uploads/avatars/" + datePath + "/" + filename
infra.Response.Success(ctx, UploadFileReply{
URI: uri,
OriginalName: fileHeader.Filename,
ContentType: contentType,
Size: int64(len(content)),
})
if claims, parseErr := sdkmiddleware.ParseAuth(ctx); parseErr == nil {
log.Printf("avatar upload client=%s account=%s type=%s size=%d uri=%s", claims.Client, claims.Identity, contentType, len(content), uri)
}
}
// ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。
func ServeAvatar(ctx *gin.Context, uri string) {
path, err := avatarPathFromURI(uri)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
file, err := os.Open(path)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
ctx.Status(http.StatusNotFound)
return
}
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("X-Content-Type-Options", "nosniff")
http.ServeContent(ctx.Writer, ctx.Request, filepath.Base(path), info.ModTime(), file)
}
// validateAvatar 验证扩展名、MIME、尺寸和完整解码结果返回规范化扩展名。
func validateAvatar(filename string, content []byte) (string, string, error) {
extension := strings.ToLower(filepath.Ext(filename))
if extension != ".jpg" && extension != ".jpeg" && extension != ".png" {
return "", "", errors.New("unsupported avatar extension")
}
contentType := http.DetectContentType(content)
expectedType := "image/jpeg"
normalizedExtension := ".jpg"
if extension == ".png" {
expectedType = "image/png"
normalizedExtension = ".png"
}
if contentType != expectedType {
return "", "", errors.New("avatar content type mismatch")
}
config, format, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil || config.Width <= 0 || config.Height <= 0 ||
config.Width > maxAvatarDimension || config.Height > maxAvatarDimension {
return "", "", errors.New("invalid avatar dimensions")
}
if (expectedType == "image/jpeg" && format != "jpeg") || (expectedType == "image/png" && format != "png") {
return "", "", errors.New("avatar format mismatch")
}
if _, _, err := image.Decode(bytes.NewReader(content)); err != nil {
return "", "", errors.New("invalid avatar content")
}
return normalizedExtension, expectedType, nil
}
// avatarPathFromURI 将数据库中的受控 URI 映射为头像目录内的真实文件路径。
func avatarPathFromURI(uri string) (string, error) {
const prefix = "/uploads/avatars/"
if !strings.HasPrefix(uri, prefix) {
return "", errors.New("avatar URI is not controlled")
}
root, err := filepath.Abs(filepath.Join(uploadRoot(), "avatars"))
if err != nil {
return "", err
}
relative := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(uri, "/uploads/avatars/")))
if relative == "." || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("invalid avatar path")
}
candidate, err := filepath.Abs(filepath.Join(root, relative))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, candidate)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", errors.New("avatar path escapes root")
}
return candidate, nil
}