Files
jiang13-bbs/backend/service/upload.go

536 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
// 头像限制:大小限制以【裁剪后】的 WebP 文件为准(前端裁剪为正方形后上传)
const (
AvatarMaxBytes = 2 << 20 // 2 MiB
AvatarMinDim = 64
AvatarMaxDim = 512
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
ImageMaxDim = 4096
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
)
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
type UploadService struct {
ops *Operations
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 {
return err
}
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
return err
}
return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755)
}
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment, error) {
if len(data) == 0 {
return nil, errors.New("文件为空")
}
if len(data) > AvatarMaxBytes {
return nil, errors.New("裁剪后的图片不能超过 2MB")
}
// WebP 魔数:RIFF????WEBP
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, errors.New("仅支持 WebP 格式图片")
}
// 解码尺寸(服务端不信任前端传入的宽高)
cfg, err := webp.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, errors.New("无法解析 WebP 图片")
}
if cfg.Width != cfg.Height {
return nil, errors.New("头像必须为正方形")
}
if cfg.Width < AvatarMinDim || cfg.Width > AvatarMaxDim {
return nil, errors.New("头像尺寸需在 64~512px 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
// 附件记录与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(att).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
})
if err != nil {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
return nil, err
}
return att, nil
}
// imageFormat 由魔数识别的插图格式
type imageFormat struct {
ext string
mime string
}
func detectImageFormat(data []byte) (imageFormat, error) {
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil
}
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
return imageFormat{ext: ".png", mime: "image/png"}, nil
}
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
}
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
}
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
var cfg image.Config
switch mime {
case "image/webp":
cfg, err = webp.DecodeConfig(r)
default:
cfg, _, err = image.DecodeConfig(r)
}
if err != nil {
return 0, 0, errors.New("无法解析图片")
}
return cfg.Width, cfg.Height, nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
}
maxB := s.imageMaxBytes()
tooLarge := func() error {
mb := int(maxB >> 20)
if mb < 1 {
mb = 1
}
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
}
// 先读魔数头,再与剩余流合并落盘,整图不进内存
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
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "images", filename)
tmp := fullPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(tmp)
if e != nil {
return nil, e
}
defer release()
}
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
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: int(total),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
return nil, err
}
return att, nil
}
// SaveBackground 保存站点背景图到 uploads/backgrounds,不进用户媒体库
func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
if src == nil {
return "", errors.New("文件为空")
}
tooLarge := func() error {
return errors.New("背景图不能超过 8MB")
}
head := make([]byte, 12)
n, err := io.ReadFull(src, head)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return "", errors.New("读取图片失败")
}
if n == 0 {
return "", errors.New("文件为空")
}
format, err := detectImageFormat(head[:n])
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
return "", err
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return "", err
}
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "backgrounds", filename)
tmp := fullPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(tmp)
if e != nil {
return "", e
}
defer release()
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return "", err
}
remain := BackgroundMaxBytes + 1 - int64(n)
if remain < 0 {
_ = f.Close()
_ = os.Remove(tmp)
return "", tooLarge()
}
if _, err := f.Write(head[:n]); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return "", err
}
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", copyErr
}
total := int64(n) + writtenRest
if total > BackgroundMaxBytes {
_ = os.Remove(tmp)
return "", tooLarge()
}
rf, err := os.Open(tmp)
if err != nil {
_ = os.Remove(tmp)
return "", err
}
w, h, err := decodeImageSizeReader(rf, format.mime)
_ = rf.Close()
if err != nil {
_ = os.Remove(tmp)
return "", err
}
if w < 1 || h < 1 {
_ = os.Remove(tmp)
return "", errors.New("无效的图片尺寸")
}
if w > BackgroundMaxDim || h > BackgroundMaxDim {
_ = os.Remove(tmp)
return "", errors.New("背景图边长不能超过 8192px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return "/uploads/backgrounds/" + filename, nil
}
// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置
func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return "", errors.New("图片不存在或不属于你")
}
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
if e != nil {
return "", e
}
defer r.Close()
return s.SaveBackground(r)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return "", errors.New("无效的图片地址")
}
f, err := os.Open(abs)
if err != nil {
return "", errors.New("读取图片失败")
}
defer f.Close()
return s.SaveBackground(f)
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
func (s *UploadService) BackgroundFileExists(url string) bool {
rel := BgRelPath(url)
if rel == "" {
return false
}
abs := filepath.Join(s.dir, rel)
info, err := os.Stat(abs)
return err == nil && !info.IsDir()
}
// RemoveBackgroundIfUnused 旧墙纸不再被前台或后台引用时删除物理文件
func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL string) {
if oldURL == "" || oldURL == siteURL || oldURL == adminURL {
return
}
rel := BgRelPath(oldURL)
if rel == "" {
return
}
abs := filepath.Join(s.dir, rel)
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除背景图失败 path=%s: %v", abs, err)
}
}
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)
if (!strings.HasPrefix(url, "/uploads/avatars/") && !strings.HasPrefix(url, "/api/media/")) || len(url) > 512 {
return errors.New("无效的头像地址")
}
var count int64
s.db.Model(&model.Attachment{}).
Where("user_id = ? AND kind = ? AND url = ?", userID, model.AttachmentKindAvatar, url).
Count(&count)
if count == 0 {
return errors.New("该图片不存在或不属于你")
}
return s.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
}
// mediaListSize 媒体库最多返回的图片数
const mediaListSize = 60
// ListMedia 媒体库:本人上传的【全部】图片(头像、帖子插图等所有 kind),最新在前
func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
var list []model.Attachment
err := s.db.Where("user_id = ?", userID).
Order("id DESC").Limit(mediaListSize).Find(&list).Error
return list, err
}
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
// DeleteAttachment 彻底删除本人附件:DB 记录 + 物理文件。
// - 若该图正被某篇未删除帖子的正文引用,拒绝删除(防止帖子里出现裂图)
// - 若该图正是当前头像,同步把 user.avatar 置空(回到字母头像)
func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return errors.New("图片不存在或不属于你")
}
// 帖子正文引用保护(GORM 默认过滤已删除帖子)
var refCount int64
s.db.Model(&model.Post{}).
Where("content LIKE ?", "%"+att.URL+"%").
Count(&refCount)
if refCount > 0 {
return ErrAttachmentInUse
}
err := s.db.Transaction(func(tx *gorm.DB) error {
// 当前头像正指向该文件:置空,避免悬挂引用
if err := tx.Model(&model.User{}).
Where("id = ? AND avatar = ?", userID, att.URL).
Update("avatar", "").Error; err != nil {
return err
}
return tx.Delete(&model.Attachment{}, att.ID).Error
})
if err != nil {
return err
}
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
}
return nil
}
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除附件文件失败 id=%d path=%s: %v", att.ID, abs, err)
}
return nil
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }