536 lines
14 KiB
Go
536 lines
14 KiB
Go
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 }
|