480 lines
12 KiB
Go
480 lines
12 KiB
Go
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"
|
||
|
||
BrandMarkImageText = "image_text"
|
||
BrandMarkImage = "image"
|
||
BrandMarkText = "text"
|
||
|
||
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"`
|
||
}
|
||
|
||
// 仅接受本站品牌目录;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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab})
|
||
}
|
||
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
|
||
}
|
||
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab})
|
||
}
|
||
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
|
||
}
|
||
if len(list) == 0 {
|
||
return s.deleteKey(SettingKeyFooterLinks)
|
||
}
|
||
// 外链未勾选时仍尊重调用方;新增行的默认新开由前端给出
|
||
b, err := json.Marshal(list)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.putValue(SettingKeyFooterLinks, string(b))
|
||
}
|