Files
jiang13-bbs/backend/service/brand_store.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

216 lines
5.8 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()
}
// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist
func (s *UploadService) OpenBrandFile(url string) (*os.File, error) {
if BrandRelPath(url) == "" {
return nil, os.ErrNotExist
}
abs, ok := s.safeUploadPath(url)
if !ok {
return nil, os.ErrNotExist
}
f, err := os.Open(abs)
if err != nil {
return nil, os.ErrNotExist
}
return f, nil
}
// 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)
}
}