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

200 lines
5.4 KiB
Go
Raw Permalink 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"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"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("图片不存在或不属于你")
}
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.SaveBrand(slot, 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.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)
}
}