136 lines
3.6 KiB
Go
136 lines
3.6 KiB
Go
|
|
package media
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/base64"
|
|||
|
|
"fmt"
|
|||
|
|
"path"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"apps/backend/internal/middleware"
|
|||
|
|
"apps/backend/internal/response"
|
|||
|
|
"apps/backend/internal/svc"
|
|||
|
|
"apps/backend/internal/types"
|
|||
|
|
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
"github.com/zeromicro/go-zero/core/logx"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const maxUploadBytes = 5 * 1024 * 1024
|
|||
|
|
|
|||
|
|
var allowedImageTypes = map[string]string{
|
|||
|
|
"image/jpeg": ".jpg",
|
|||
|
|
"image/jpg": ".jpg",
|
|||
|
|
"image/png": ".png",
|
|||
|
|
"image/webp": ".webp",
|
|||
|
|
"image/gif": ".gif",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type UploadLogic struct {
|
|||
|
|
logx.Logger
|
|||
|
|
ctx context.Context
|
|||
|
|
svcCtx *svc.ServiceContext
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewUploadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadLogic {
|
|||
|
|
return &UploadLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Upload 解 data URL → object storage → 回公開 URL(Mongo 只存此 URL)。
|
|||
|
|
func (l *UploadLogic) Upload(req *types.MediaUploadReq) (*types.MediaUploadData, error) {
|
|||
|
|
if l.svcCtx.Storage == nil || !l.svcCtx.Storage.Enabled() {
|
|||
|
|
return nil, response.Biz(503, 503001, "object storage not configured")
|
|||
|
|
}
|
|||
|
|
uid, ok := middleware.UIDFrom(l.ctx)
|
|||
|
|
if !ok || uid <= 0 {
|
|||
|
|
return nil, response.Biz(401, 401001, "missing authorization")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
contentType, raw, err := decodeImageContent(req.Content)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.Biz(400, 400001, err.Error())
|
|||
|
|
}
|
|||
|
|
if len(raw) > maxUploadBytes {
|
|||
|
|
return nil, response.Biz(400, 400001, "file size exceeds 5MB limit")
|
|||
|
|
}
|
|||
|
|
ext, ok := allowedImageTypes[contentType]
|
|||
|
|
if !ok {
|
|||
|
|
return nil, response.Biz(400, 400001, "unsupported image type (jpeg/png/webp/gif only)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
|||
|
|
if kind == "" {
|
|||
|
|
kind = "avatar"
|
|||
|
|
}
|
|||
|
|
if kind != "avatar" && kind != "other" {
|
|||
|
|
kind = "other"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
id := uuid.NewString()
|
|||
|
|
key := path.Join(kind, fmt.Sprintf("%d", uid), id+ext)
|
|||
|
|
|
|||
|
|
url, err := l.svcCtx.Storage.Upload(l.ctx, key, raw, contentType)
|
|||
|
|
if err != nil {
|
|||
|
|
l.Errorf("storage upload: %v", err)
|
|||
|
|
return nil, response.Biz(500, 500000, "file upload failed")
|
|||
|
|
}
|
|||
|
|
return &types.MediaUploadData{Url: url, Key: key}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func decodeImageContent(content string) (contentType string, data []byte, err error) {
|
|||
|
|
content = strings.TrimSpace(content)
|
|||
|
|
if content == "" {
|
|||
|
|
return "", nil, fmt.Errorf("empty content")
|
|||
|
|
}
|
|||
|
|
// data:image/jpeg;base64,....
|
|||
|
|
if strings.HasPrefix(content, "data:") {
|
|||
|
|
parts := strings.SplitN(content, ",", 2)
|
|||
|
|
if len(parts) != 2 {
|
|||
|
|
return "", nil, fmt.Errorf("invalid data url")
|
|||
|
|
}
|
|||
|
|
meta := parts[0] // data:image/jpeg;base64
|
|||
|
|
meta = strings.TrimPrefix(meta, "data:")
|
|||
|
|
semi := strings.Index(meta, ";")
|
|||
|
|
if semi < 0 {
|
|||
|
|
return "", nil, fmt.Errorf("invalid data url meta")
|
|||
|
|
}
|
|||
|
|
contentType = strings.TrimSpace(meta[:semi])
|
|||
|
|
payload := parts[1]
|
|||
|
|
data, err = base64.StdEncoding.DecodeString(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
// try raw std without padding issues
|
|||
|
|
data, err = base64.RawStdEncoding.DecodeString(payload)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
return "", nil, fmt.Errorf("invalid base64")
|
|||
|
|
}
|
|||
|
|
return contentType, data, nil
|
|||
|
|
}
|
|||
|
|
// pure base64 — sniff
|
|||
|
|
data, err = base64.StdEncoding.DecodeString(content)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", nil, fmt.Errorf("invalid base64")
|
|||
|
|
}
|
|||
|
|
contentType = sniffImage(data)
|
|||
|
|
if contentType == "" {
|
|||
|
|
return "", nil, fmt.Errorf("cannot detect image type")
|
|||
|
|
}
|
|||
|
|
return contentType, data, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func sniffImage(b []byte) string {
|
|||
|
|
if len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff {
|
|||
|
|
return "image/jpeg"
|
|||
|
|
}
|
|||
|
|
if len(b) >= 8 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47 {
|
|||
|
|
return "image/png"
|
|||
|
|
}
|
|||
|
|
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
|
|||
|
|
return "image/gif"
|
|||
|
|
}
|
|||
|
|
if len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
|
|||
|
|
return "image/webp"
|
|||
|
|
}
|
|||
|
|
return ""
|
|||
|
|
}
|