Files
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

534 lines
14 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 (
"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"
SettingKeyBrandSloganVisible = "brand_slogan_visible"
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"
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
BrandLogoSizeAuto = "auto"
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, BrandLogoSizeAuto:
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
}
}
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
if err != nil {
return err
}
if found {
out.BrandSloganVisible = sv == "true"
} else {
out.BrandSloganVisible = true
}
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)
}
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
if visible {
return s.deleteKey(SettingKeyBrandSloganVisible)
}
return s.putValue(SettingKeyBrandSloganVisible, "false")
}
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))
}