落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
2.8 KiB
Go
120 lines
2.8 KiB
Go
// Package avatar stores and serves user profile photos.
|
|
package avatar
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const MaxBytes = 2 << 20 // 2 MiB
|
|
|
|
var (
|
|
ErrTooLarge = errors.New("图片过大,请选择 2MB 以内")
|
|
ErrBadType = errors.New("仅支持 JPG / PNG / WebP")
|
|
ErrEmpty = errors.New("请选择图片")
|
|
ErrBadName = errors.New("无效头像")
|
|
)
|
|
|
|
// Store writes multipart image under dir as {userID}{ext}; returns public API path.
|
|
func Store(dir string, userID uuid.UUID, fh *multipart.FileHeader) (publicPath string, err error) {
|
|
if fh == nil || fh.Size <= 0 {
|
|
return "", ErrEmpty
|
|
}
|
|
if fh.Size > MaxBytes {
|
|
return "", ErrTooLarge
|
|
}
|
|
ext, err := extFor(fh.Filename, fh.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
name := userID.String() + ext
|
|
dstPath := filepath.Join(dir, name)
|
|
tmp := dstPath + ".tmp"
|
|
out, err := os.Create(tmp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
n, copyErr := io.Copy(out, io.LimitReader(src, MaxBytes+1))
|
|
_ = out.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", copyErr
|
|
}
|
|
if n > MaxBytes {
|
|
_ = os.Remove(tmp)
|
|
return "", ErrTooLarge
|
|
}
|
|
// remove previous extensions for same user
|
|
for _, old := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
|
p := filepath.Join(dir, userID.String()+old)
|
|
if p != dstPath {
|
|
_ = os.Remove(p)
|
|
}
|
|
}
|
|
if err := os.Rename(tmp, dstPath); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", err
|
|
}
|
|
return "/api/v1/media/avatars/" + name, nil
|
|
}
|
|
|
|
// ResolveAbs validates file name and returns absolute path under dir.
|
|
func ResolveAbs(dir, name string) (string, error) {
|
|
base := filepath.Base(name)
|
|
if base != name || strings.Contains(base, "..") {
|
|
return "", ErrBadName
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
|
return "", ErrBadName
|
|
}
|
|
idPart := strings.TrimSuffix(base, ext)
|
|
if _, err := uuid.Parse(idPart); err != nil {
|
|
return "", ErrBadName
|
|
}
|
|
full := filepath.Join(dir, base)
|
|
if _, err := os.Stat(full); err != nil {
|
|
return "", ErrBadName
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
func extFor(filename, contentType string) (string, error) {
|
|
ct := strings.ToLower(contentType)
|
|
switch {
|
|
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
|
|
return ".jpg", nil
|
|
case strings.Contains(ct, "png"):
|
|
return ".png", nil
|
|
case strings.Contains(ct, "webp"):
|
|
return ".webp", nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
case ".jpg", ".jpeg":
|
|
return ".jpg", nil
|
|
case ".png":
|
|
return ".png", nil
|
|
case ".webp":
|
|
return ".webp", nil
|
|
default:
|
|
return "", fmt.Errorf("%w", ErrBadType)
|
|
}
|
|
}
|