feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestResolveAbsRejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := uuid.New()
|
||||
name := id.String() + ".jpg"
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveAbs(dir, "../"+name); err == nil {
|
||||
t.Fatal("expected reject ..")
|
||||
}
|
||||
if _, err := ResolveAbs(dir, "not-a-uuid.jpg"); err == nil {
|
||||
t.Fatal("expected reject bad id")
|
||||
}
|
||||
if _, err := ResolveAbs(dir, id.String()+".gif"); err == nil {
|
||||
t.Fatal("expected reject gif")
|
||||
}
|
||||
full, err := ResolveAbs(dir, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(full) != name {
|
||||
t.Fatalf("full=%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsBadTypeAndSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
uid := uuid.New()
|
||||
|
||||
fhExe := mustFormFile(t, "x.exe", "application/octet-stream", []byte("MZ"))
|
||||
if _, err := Store(dir, uid, fhExe); err == nil {
|
||||
t.Fatal("expected bad type")
|
||||
}
|
||||
|
||||
big := bytes.Repeat([]byte("a"), MaxBytes+10)
|
||||
fhBig := mustFormFile(t, "big.jpg", "image/jpeg", big)
|
||||
if _, err := Store(dir, uid, fhBig); err == nil {
|
||||
t.Fatal("expected too large")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOKAndOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
uid := uuid.New()
|
||||
fh := mustFormFile(t, "a.png", "image/png", []byte{0x89, 0x50, 0x4e, 0x47})
|
||||
path, err := Store(dir, uid, fh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "/api/v1/media/avatars/" + uid.String() + ".png"
|
||||
if path != want {
|
||||
t.Fatalf("path=%s want=%s", path, want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fh2 := mustFormFile(t, "b.jpg", "image/jpeg", []byte{0xff, 0xd8, 0xff})
|
||||
path2, err := Store(dir, uid, fh2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path2 != "/api/v1/media/avatars/"+uid.String()+".jpg" {
|
||||
t.Fatalf("path2=%s", path2)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); !os.IsNotExist(err) {
|
||||
t.Fatal("old png should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func mustFormFile(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
|
||||
h.Set("Content-Type", contentType)
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := multipart.NewReader(&buf, w.Boundary())
|
||||
form, err := r.ReadForm(int64(len(data)) + 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = form.RemoveAll() })
|
||||
files := form.File["file"]
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no file part")
|
||||
}
|
||||
return files[0]
|
||||
}
|
||||
Reference in New Issue
Block a user