// 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 }