feat: 新增账户资料页与头像上传
将工作人员和用户的详情、编辑改为独立账户资料页。 增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。 同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
171
backend/api/internal/logic/upload/avatar.go
Normal file
171
backend/api/internal/logic/upload/avatar.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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
|
||||
}
|
||||
70
backend/api/internal/logic/upload/avatar_test.go
Normal file
70
backend/api/internal/logic/upload/avatar_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Package upload 测试头像上传的格式、尺寸与路径安全边界。
|
||||
// 版本:v1.0.0
|
||||
package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes 生成指定尺寸的有效 PNG 测试图片。
|
||||
func pngBytes(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
picture := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
picture.Set(0, 0, color.RGBA{R: 32, G: 96, B: 192, A: 255})
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, picture); err != nil {
|
||||
t.Fatalf("encode PNG: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
// TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。
|
||||
func TestValidateAvatarAcceptsRealPNG(t *testing.T) {
|
||||
extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatalf("valid PNG was rejected: %v", err)
|
||||
}
|
||||
if extension != ".png" || contentType != "image/png" {
|
||||
t.Fatalf("avatar metadata = (%q, %q)", extension, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAvatarRejectsSpoofedAndOversizedImage 验证伪造扩展名与超大像素尺寸会被拒绝。
|
||||
func TestValidateAvatarRejectsSpoofedAndOversizedImage(t *testing.T) {
|
||||
if _, _, err := validateAvatar("fake.png", []byte("not an image")); err == nil {
|
||||
t.Fatal("spoofed PNG was accepted")
|
||||
}
|
||||
if _, _, err := validateAvatar("wide.png", pngBytes(t, maxAvatarDimension+1, 1)); err == nil {
|
||||
t.Fatal("oversized image dimensions were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAvatarPathFromURIStaysInsideControlledRoot 验证头像 URI 不能逃逸受控目录。
|
||||
func TestAvatarPathFromURIStaysInsideControlledRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("HEQI_UPLOAD_DIR", root)
|
||||
path, err := avatarPathFromURI("/uploads/avatars/2026/08/10/example.png")
|
||||
if err != nil {
|
||||
t.Fatalf("controlled URI was rejected: %v", err)
|
||||
}
|
||||
expectedRoot := filepath.Join(root, "avatars")
|
||||
relative, err := filepath.Rel(expectedRoot, path)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
t.Fatalf("avatar path escaped root: %q", path)
|
||||
}
|
||||
for _, uri := range []string{
|
||||
"https://example.com/avatar.png",
|
||||
"/uploads/example.png",
|
||||
"/uploads/avatars/../../secret.png",
|
||||
} {
|
||||
if _, err := avatarPathFromURI(uri); err == nil {
|
||||
t.Fatalf("unsafe avatar URI was accepted: %q", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user