feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,9 +8,11 @@ import (
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -31,14 +33,32 @@ const (
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
type UploadService struct {
|
||||
db *gorm.DB
|
||||
dir string // 上传根目录(如 data/uploads)
|
||||
db *gorm.DB
|
||||
dir string // 上传根目录(如 data/uploads)
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||||
return &UploadService{db: db, dir: uploadDir}
|
||||
}
|
||||
|
||||
// WithSetting 注入站点设置(插图体积上限)
|
||||
func (s *UploadService) WithSetting(setting *SettingService) *UploadService {
|
||||
s.setting = setting
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *UploadService) imageMaxBytes() int64 {
|
||||
if s.setting == nil {
|
||||
return ImageMaxBytes
|
||||
}
|
||||
n, err := s.setting.ImageMaxBytes()
|
||||
if err != nil || n < 1 {
|
||||
return ImageMaxBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// EnsureDir 启动时确保上传目录存在
|
||||
func (s *UploadService) EnsureDir() error {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||||
@@ -125,8 +145,7 @@ func detectImageFormat(data []byte) (imageFormat, error) {
|
||||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
|
||||
}
|
||||
|
||||
func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
r := bytes.NewReader(data)
|
||||
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||||
var cfg image.Config
|
||||
switch mime {
|
||||
case "image/webp":
|
||||
@@ -140,28 +159,33 @@ func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
|
||||
if len(data) == 0 {
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > ImageMaxBytes {
|
||||
return nil, errors.New("图片不能超过 5MB")
|
||||
maxB := s.imageMaxBytes()
|
||||
tooLarge := func() error {
|
||||
mb := int(maxB >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
|
||||
// 先读魔数头,再与剩余流合并落盘,整图不进内存
|
||||
head := make([]byte, 12)
|
||||
n, err := io.ReadFull(src, head)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("读取图片失败")
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
format, err := detectImageFormat(head[:n])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSize(data, format.mime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
@@ -169,7 +193,61 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "images", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
tmp := fullPath + ".partial"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remain := maxB + 1 - int64(n)
|
||||
if remain < 0 {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return nil, tooLarge()
|
||||
}
|
||||
writtenHead, err := f.Write(head[:n])
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, copyErr
|
||||
}
|
||||
total := int64(writtenHead) + writtenRest
|
||||
if total == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if total > maxB {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, tooLarge()
|
||||
}
|
||||
|
||||
rf, err := os.Open(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||||
_ = rf.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
if err := os.Rename(tmp, fullPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -178,7 +256,7 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: len(data),
|
||||
Size: int(total),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user