Files
platforms/backend/api/internal/logic/upload/avatar.go
czl231 7242048abf feat: 新增账户资料页与头像上传
将工作人员和用户的详情、编辑改为独立账户资料页。

增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。

同步更新平台需求、接口安全说明、项目文档和操作日志。
2026-08-10 22:35:47 +08:00

172 lines
5.4 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 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
}