Files
jiang13-bbs/backend/service/brand.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

514 lines
13 KiB
Go
Raw 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 (
"encoding/json"
"regexp"
"strings"
"unicode/utf8"
)
// 站点品牌与页脚(公开设置,缺省为空)
const (
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
BrandSlotLight = "logo_light"
BrandSlotDark = "logo_dark"
BrandSlotFavicon = "favicon"
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
BrandAliasPathLight = "/api/brand/logo-light"
BrandAliasPathDark = "/api/brand/logo-dark"
BrandAliasPathFavicon = "/api/brand/favicon"
BrandMarkImageText = "image_text"
BrandMarkImage = "image"
BrandMarkText = "text"
FooterLinkAlignLeft = "left"
FooterLinkAlignCenter = "center"
FooterLinkAlignRight = "right"
BrandLogoSizeSQ = "sq"
BrandLogoSize2x1 = "2x1"
BrandLogoSize3x1 = "3x1"
BrandLogoSize4x1 = "4x1"
BrandLogoSize169 = "16x9"
BrandLogoFitCover = "cover"
BrandLogoFitContain = "contain"
BrandLogoFitRepeat = "repeat"
BrandLogoFitStretch = "stretch"
MaxWordmarkRunes = 16
MaxSloganRunes = 32
MaxKeywordCount = 16
MaxKeywordRunes = 20
MaxFooterLinks = 8
MaxFooterLabelRunes = 32
MaxFooterURLRunes = 200
)
// FooterLink 页脚外链 / 站内链(备案、协议等)
type FooterLink struct {
Label string `json:"label"`
URL string `json:"url"`
NewTab bool `json:"new_tab"`
Align string `json:"align"`
}
// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico
var (
brandFileURLRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg|ico)$`)
brandLogoURLRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg)$`)
brandFaviconRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg|ico)$`)
footerHTTPURLRe = regexp.MustCompile(`(?i)^https?://`)
)
// NormalizeBrandSlot 浅色 Logo / 暗色 Logo / Favicon
func NormalizeBrandSlot(raw string) (string, bool) {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case BrandSlotLight, BrandSlotDark, BrandSlotFavicon:
return s, true
default:
return "", false
}
}
// NormalizeBrandMark 页眉品牌形式:图加文字 / 纯图 / 纯文字。空串视为图加文字。
func NormalizeBrandMark(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandMarkImageText:
return BrandMarkImageText, true
case BrandMarkImage, BrandMarkText:
return strings.TrimSpace(raw), true
default:
return "", false
}
}
// NormalizeBrandLogoFit 图槽铺法:填充 / 适应 / 平铺 / 拉伸。空串视为适应。
func NormalizeBrandLogoFit(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandLogoFitContain:
return BrandLogoFitContain, true
case BrandLogoFitCover, BrandLogoFitRepeat, BrandLogoFitStretch:
return strings.TrimSpace(raw), true
default:
return "", false
}
}
// NormalizeBrandLogoSize 图槽画幅。空串视为方标。图加文字和纯图共用。
func NormalizeBrandLogoSize(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandLogoSizeSQ:
return BrandLogoSizeSQ, true
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
return strings.TrimSpace(raw), true
default:
return "", false
}
}
// NormalizeBrandURL 校验品牌图 URL;空串表示清除。slot 决定是否允许 ico。
func NormalizeBrandURL(slot, raw string) (string, bool) {
u := strings.ToLower(strings.TrimSpace(raw))
if u == "" {
return "", true
}
switch slot {
case BrandSlotLight, BrandSlotDark:
if !brandLogoURLRe.MatchString(u) {
return "", false
}
case BrandSlotFavicon:
if !brandFaviconRe.MatchString(u) {
return "", false
}
default:
return "", false
}
return u, true
}
// BrandRelPath 把合法品牌 URL 转成 uploads 根下的相对路径;非法返回空
func BrandRelPath(url string) string {
u := strings.ToLower(strings.TrimSpace(url))
if u == "" || !brandFileURLRe.MatchString(u) {
return ""
}
return strings.TrimPrefix(u, "/uploads/")
}
// NormalizeKeywords 去空白、按逗号拆开、去重;超限返回 ErrInvalidSiteSetting
func NormalizeKeywords(raw []string) ([]string, error) {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, item := range raw {
parts := strings.FieldsFunc(item, func(r rune) bool {
return r == ',' || r == ',' || r == ';' || r == ';'
})
if len(parts) == 0 {
parts = []string{item}
}
for _, p := range parts {
k := strings.TrimSpace(p)
if k == "" {
continue
}
if utf8.RuneCountInString(k) > MaxKeywordRunes {
return nil, ErrInvalidSiteSetting
}
key := strings.ToLower(k)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, k)
if len(out) > MaxKeywordCount {
return nil, ErrInvalidSiteSetting
}
}
}
return out, nil
}
func keywordsToStore(list []string) string {
return strings.Join(list, ",")
}
func keywordsFromStore(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
list, err := NormalizeKeywords([]string{raw})
if err != nil || list == nil {
return []string{}
}
return list
}
func isExternalFooterURL(u string) bool {
return footerHTTPURLRe.MatchString(u)
}
// NormalizeFooterURL 站内路径或以 http(s) 开头;拒绝协议相对与脚本地址
func NormalizeFooterURL(raw string) (string, bool) {
u := strings.TrimSpace(raw)
if u == "" || utf8.RuneCountInString(u) > MaxFooterURLRunes {
return "", false
}
if strings.ContainsAny(u, " \t\r\n\\") {
return "", false
}
lower := strings.ToLower(u)
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
return "", false
}
if strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//") {
return u, true
}
if footerHTTPURLRe.MatchString(u) {
return u, true
}
return "", false
}
type footerLinkIn struct {
Label string `json:"label"`
URL string `json:"url"`
NewTab *bool `json:"new_tab"`
Align string `json:"align"`
}
// NormalizeFooterLinkAlign 单条页脚链接所属栏位;空串视为居右。
func NormalizeFooterLinkAlign(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", FooterLinkAlignRight:
return FooterLinkAlignRight, true
case FooterLinkAlignLeft, FooterLinkAlignCenter:
return strings.TrimSpace(raw), true
default:
return "", false
}
}
// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签
func NormalizeFooterLinks(raw []FooterLink) ([]FooterLink, error) {
if len(raw) > MaxFooterLinks {
return nil, ErrInvalidSiteSetting
}
out := make([]FooterLink, 0, len(raw))
for _, item := range raw {
label := strings.TrimSpace(item.Label)
if label == "" || utf8.RuneCountInString(label) > MaxFooterLabelRunes {
return nil, ErrInvalidSiteSetting
}
u, ok := NormalizeFooterURL(item.URL)
if !ok {
return nil, ErrInvalidSiteSetting
}
align, ok := NormalizeFooterLinkAlign(item.Align)
if !ok {
return nil, ErrInvalidSiteSetting
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab, Align: align})
}
return out, nil
}
func parseFooterLinksJSON(raw string) ([]FooterLink, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return []FooterLink{}, nil
}
var in []footerLinkIn
if err := json.Unmarshal([]byte(raw), &in); err != nil {
return []FooterLink{}, nil
}
if len(in) > MaxFooterLinks {
return []FooterLink{}, nil
}
out := make([]FooterLink, 0, len(in))
for _, item := range in {
label := strings.TrimSpace(item.Label)
u, ok := NormalizeFooterURL(item.URL)
if label == "" || utf8.RuneCountInString(label) > MaxFooterLabelRunes || !ok {
continue
}
newTab := false
if item.NewTab != nil {
newTab = *item.NewTab
} else if isExternalFooterURL(u) {
newTab = true
}
align, ok := NormalizeFooterLinkAlign(item.Align)
if !ok {
align = FooterLinkAlignRight
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab, Align: align})
}
return out, nil
}
func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
wm, found, err := s.getValue(SettingKeySiteWordmark)
if err != nil {
return err
}
if found {
out.SiteWordmark = strings.TrimSpace(wm)
}
slogan, found, err := s.getValue(SettingKeySiteSlogan)
if err != nil {
return err
}
if found {
out.SiteSlogan = strings.TrimSpace(slogan)
}
kw, found, err := s.getValue(SettingKeySiteKeywords)
if err != nil {
return err
}
if found {
out.SiteKeywords = keywordsFromStore(kw)
}
light, err := s.brandURL(SettingKeyLogoLightURL, BrandSlotLight)
if err != nil {
return err
}
out.LogoLightURL = light
dark, err := s.brandURL(SettingKeyLogoDarkURL, BrandSlotDark)
if err != nil {
return err
}
out.LogoDarkURL = dark
fav, err := s.brandURL(SettingKeyFaviconURL, BrandSlotFavicon)
if err != nil {
return err
}
out.FaviconURL = fav
mark, found, err := s.getValue(SettingKeyBrandMark)
if err != nil {
return err
}
if found {
if m, ok := NormalizeBrandMark(mark); ok {
out.BrandMark = m
}
}
size, found, err := s.getValue(SettingKeyBrandLogoSize)
if err != nil {
return err
}
if found {
if sz, ok := NormalizeBrandLogoSize(size); ok {
out.BrandLogoSize = sz
}
}
fit, found, err := s.getValue(SettingKeyBrandLogoFit)
if err != nil {
return err
}
if found {
if f, ok := NormalizeBrandLogoFit(fit); ok {
out.BrandLogoFit = f
}
}
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
if err != nil {
return err
}
if found {
links, perr := parseFooterLinksJSON(rawLinks)
if perr != nil {
return perr
}
out.FooterLinks = links
}
return nil
}
func (s *SettingService) brandURL(key, slot string) (string, error) {
v, found, err := s.getValue(key)
if err != nil || !found {
return "", err
}
u, ok := NormalizeBrandURL(slot, v)
if !ok {
return "", nil
}
return u, nil
}
func (s *SettingService) LogoLightURL() (string, error) {
return s.brandURL(SettingKeyLogoLightURL, BrandSlotLight)
}
func (s *SettingService) LogoDarkURL() (string, error) {
return s.brandURL(SettingKeyLogoDarkURL, BrandSlotDark)
}
func (s *SettingService) FaviconURL() (string, error) {
return s.brandURL(SettingKeyFaviconURL, BrandSlotFavicon)
}
func (s *SettingService) SetSiteWordmark(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return s.deleteKey(SettingKeySiteWordmark)
}
if utf8.RuneCountInString(name) > MaxWordmarkRunes {
return ErrInvalidSiteSetting
}
return s.putValue(SettingKeySiteWordmark, name)
}
func (s *SettingService) SetSiteSlogan(slogan string) error {
slogan = strings.TrimSpace(slogan)
if slogan == "" {
return s.deleteKey(SettingKeySiteSlogan)
}
if utf8.RuneCountInString(slogan) > MaxSloganRunes {
return ErrInvalidSiteSetting
}
return s.putValue(SettingKeySiteSlogan, slogan)
}
func (s *SettingService) SetSiteKeywords(raw []string) error {
list, err := NormalizeKeywords(raw)
if err != nil {
return err
}
if len(list) == 0 {
return s.deleteKey(SettingKeySiteKeywords)
}
return s.putValue(SettingKeySiteKeywords, keywordsToStore(list))
}
func (s *SettingService) SetLogoLightURL(url string) error {
return s.setBrandURL(SettingKeyLogoLightURL, BrandSlotLight, url)
}
func (s *SettingService) SetLogoDarkURL(url string) error {
return s.setBrandURL(SettingKeyLogoDarkURL, BrandSlotDark, url)
}
func (s *SettingService) SetFaviconURL(url string) error {
return s.setBrandURL(SettingKeyFaviconURL, BrandSlotFavicon, url)
}
func (s *SettingService) SetBrandMark(raw string) error {
v, ok := NormalizeBrandMark(raw)
if !ok {
return ErrInvalidSiteSetting
}
if v == BrandMarkImageText {
return s.deleteKey(SettingKeyBrandMark)
}
return s.putValue(SettingKeyBrandMark, v)
}
func (s *SettingService) SetBrandLogoSize(raw string) error {
v, ok := NormalizeBrandLogoSize(raw)
if !ok {
return ErrInvalidSiteSetting
}
if v == BrandLogoSizeSQ {
return s.deleteKey(SettingKeyBrandLogoSize)
}
return s.putValue(SettingKeyBrandLogoSize, v)
}
func (s *SettingService) SetBrandLogoFit(raw string) error {
v, ok := NormalizeBrandLogoFit(raw)
if !ok {
return ErrInvalidSiteSetting
}
if v == BrandLogoFitContain {
return s.deleteKey(SettingKeyBrandLogoFit)
}
return s.putValue(SettingKeyBrandLogoFit, v)
}
func (s *SettingService) setBrandURL(key, slot, url string) error {
u, ok := NormalizeBrandURL(slot, url)
if !ok {
return ErrInvalidSiteSetting
}
if u == "" {
return s.deleteKey(key)
}
return s.putValue(key, u)
}
func (s *SettingService) SetFooterLinks(raw []FooterLink) error {
list, err := NormalizeFooterLinks(raw)
if err != nil {
return err
}
// 去掉曾用的站级对齐键(现改为逐条 align)
_ = s.deleteKey("footer_links_align")
if len(list) == 0 {
return s.deleteKey(SettingKeyFooterLinks)
}
// 外链未勾选时仍尊重调用方;新增行的默认新开由前端给出
b, err := json.Marshal(list)
if err != nil {
return err
}
return s.putValue(SettingKeyFooterLinks, string(b))
}