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