188 lines
5.1 KiB
Go
188 lines
5.1 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
)
|
||
|
||
const (
|
||
BrandLogoMaxBytes = 2 << 20 // 2 MiB
|
||
BrandFaviconMaxBytes = 512 << 10 // 512 KiB
|
||
brandMaxDim = 2048
|
||
)
|
||
|
||
// ErrBrandFileMissing 设置了合法 URL 但磁盘上没有对应文件
|
||
var ErrBrandFileMissing = errors.New("品牌图文件不存在")
|
||
|
||
type brandFormat struct {
|
||
ext string
|
||
mime string
|
||
svg bool
|
||
ico bool
|
||
}
|
||
|
||
func brandMaxBytes(slot string) int64 {
|
||
if slot == BrandSlotFavicon {
|
||
return BrandFaviconMaxBytes
|
||
}
|
||
return BrandLogoMaxBytes
|
||
}
|
||
|
||
func detectBrandFormat(data []byte, slot string) (brandFormat, error) {
|
||
logo := slot != BrandSlotFavicon
|
||
too := "仅支持 PNG / WebP / SVG"
|
||
if !logo {
|
||
too = "仅支持 PNG / WebP / SVG / ICO"
|
||
}
|
||
fail := errors.New(too)
|
||
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||
return brandFormat{ext: ".png", mime: "image/png"}, nil
|
||
}
|
||
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||
return brandFormat{ext: ".webp", mime: "image/webp"}, nil
|
||
}
|
||
if !logo && len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 {
|
||
return brandFormat{ext: ".ico", mime: "image/x-icon", ico: true}, nil
|
||
}
|
||
if looksLikeSVG(data) {
|
||
return brandFormat{ext: ".svg", mime: "image/svg+xml", svg: true}, nil
|
||
}
|
||
return brandFormat{}, fail
|
||
}
|
||
|
||
func looksLikeSVG(data []byte) bool {
|
||
s := strings.ToLower(string(data))
|
||
if !strings.Contains(s, "<svg") {
|
||
return false
|
||
}
|
||
if strings.Contains(s, "<script") || strings.Contains(s, "javascript:") {
|
||
return false
|
||
}
|
||
if strings.Contains(s, "onload=") || strings.Contains(s, "onerror=") {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// SaveBrand 把品牌图落到 uploads/brand,不写站点设置
|
||
func (s *UploadService) SaveBrand(slot string, src io.Reader) (string, error) {
|
||
slot, ok := NormalizeBrandSlot(slot)
|
||
if !ok {
|
||
return "", errors.New("无效的品牌图位置")
|
||
}
|
||
if src == nil {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
maxB := brandMaxBytes(slot)
|
||
data, err := io.ReadAll(io.LimitReader(src, maxB+1))
|
||
if err != nil {
|
||
return "", errors.New("读取图片失败")
|
||
}
|
||
if len(data) == 0 {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
if int64(len(data)) > maxB {
|
||
if slot == BrandSlotFavicon {
|
||
return "", errors.New("Favicon 不能超过 512KB")
|
||
}
|
||
return "", errors.New("Logo 不能超过 2MB")
|
||
}
|
||
format, err := detectBrandFormat(data, slot)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !format.svg && !format.ico {
|
||
w, h, derr := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||
if derr != nil {
|
||
return "", derr
|
||
}
|
||
if w < 1 || h < 1 || w > brandMaxDim || h > brandMaxDim {
|
||
return "", errors.New("图片尺寸超出限制")
|
||
}
|
||
}
|
||
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "brand"), 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, "brand", filename)
|
||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
return "/uploads/brand/" + filename, nil
|
||
}
|
||
|
||
// CopyBrandFromMedia 把当前用户媒体库里的一张图复制进品牌目录,不写站点设置
|
||
func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot string) (string, error) {
|
||
if _, ok := NormalizeBrandSlot(slot); !ok {
|
||
return "", errors.New("无效的品牌图位置")
|
||
}
|
||
var att model.Attachment
|
||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||
return "", errors.New("图片不存在或不属于你")
|
||
}
|
||
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.SaveBrand(slot, f)
|
||
}
|
||
|
||
func (s *UploadService) safeUploadPath(url string) (string, bool) {
|
||
rel := strings.TrimPrefix(strings.TrimSpace(url), "/uploads/")
|
||
if rel == "" || rel == url || strings.Contains(rel, "..") {
|
||
return "", false
|
||
}
|
||
abs := filepath.Join(s.dir, filepath.FromSlash(rel))
|
||
root := filepath.Clean(s.dir)
|
||
clean := filepath.Clean(abs)
|
||
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||
return "", false
|
||
}
|
||
return clean, true
|
||
}
|
||
|
||
// BrandFileExists 确认 URL 对应文件在 brand 目录内
|
||
func (s *UploadService) BrandFileExists(url string) bool {
|
||
rel := BrandRelPath(url)
|
||
if rel == "" {
|
||
return false
|
||
}
|
||
abs := filepath.Join(s.dir, filepath.FromSlash(rel))
|
||
info, err := os.Stat(abs)
|
||
return err == nil && !info.IsDir()
|
||
}
|
||
|
||
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
|
||
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
||
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
||
return
|
||
}
|
||
rel := BrandRelPath(oldURL)
|
||
if rel == "" {
|
||
return
|
||
}
|
||
abs := filepath.Join(s.dir, filepath.FromSlash(rel))
|
||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||
log.Printf("[upload] 删除品牌图失败 path=%s: %v", abs, err)
|
||
}
|
||
}
|