后台站点设计UIUX优化
This commit is contained in:
187
backend/service/brand_store.go
Normal file
187
backend/service/brand_store.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user