后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

View 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)
}
}