package models import ( "fmt" "regexp" "strconv" "strings" "time" "unicode" "git.golem15.com/golem15/summercms/modules/lagoon" ) // The query parameter kinds of the D-07 list. const ( ParamContent = "content" ParamFilter = "filter" ParamTracking = "tracking" ) // The address options of D-20b. const ( HostKeep = "keep" HostWWW = "www" HostBare = "bare" SlashKeep = "keep" SlashAdd = "add" SlashDrop = "remove" ) // Default templates (D-03). const ( DefaultTitleTemplate = "%title% | %site%" DefaultHomeTemplate = "%site%" ) // The phrase keys of the settings refusals. const ( errVerifyFilenameInvalid = "golem15.seo::lang.settings.verify_filename_invalid" errVerifyFilenameTaken = "golem15.seo::lang.settings.verify_filename_taken" errTemplateUnknown = "golem15.seo::lang.settings.template_unknown" errDomainInvalid = "golem15.seo::lang.settings.domain_invalid" ) // QueryParam is one row of the D-07 query-parameter list: a name (a // trailing * matches a prefix), its kind, and an optional path pattern the // row is limited to. type QueryParam struct { Name string `json:"name"` Kind string `json:"kind"` Path string `json:"path"` } // VerifyMeta is one extra verification meta tag (D-05). type VerifyMeta struct { Name string `json:"name"` Content string `json:"content"` } // VerifyFile is one verification file served from the site root (D-05). type VerifyFile struct { Filename string `json:"filename"` Body string `json:"body"` } // DefaultQueryParams is the seeded D-07 list. func DefaultQueryParams() []QueryParam { return []QueryParam{ {Name: "page", Kind: ParamContent}, {Name: "utm_*", Kind: ParamTracking}, {Name: "fbclid", Kind: ParamTracking}, {Name: "gclid", Kind: ParamTracking}, {Name: "msclkid", Kind: ParamTracking}, {Name: "sort", Kind: ParamFilter}, } } // Settings is the SEO settings singleton (row id 1) on golem15_seo_settings // (D-03, D-05, D-07, D-20b, D-21, D-23). site_name and default_description // are translatable; the other locales live with the translate plugin. type Settings struct { ID uint `gorm:"column:id;primaryKey"` SiteName string `gorm:"column:site_name"` TwitterSite string `gorm:"column:twitter_site"` TitleTemplate string `gorm:"column:title_template"` HomeTemplate string `gorm:"column:home_template"` DefaultDescription string `gorm:"column:default_description"` DefaultOGImage string `gorm:"column:default_og_image"` QueryParams lagoon.Jsonable[[]QueryParam] `gorm:"column:query_params"` NoindexPaths string `gorm:"column:noindex_paths"` SearchPaths string `gorm:"column:search_paths"` URLHost string `gorm:"column:url_host"` URLSlash string `gorm:"column:url_slash"` URLLowercase bool `gorm:"column:url_lowercase"` VerifyGoogle string `gorm:"column:verify_google"` VerifyBing string `gorm:"column:verify_bing"` VerifyYandex string `gorm:"column:verify_yandex"` VerifyOther lagoon.Jsonable[[]VerifyMeta] `gorm:"column:verify_other"` VerifyFiles lagoon.Jsonable[[]VerifyFile] `gorm:"column:verify_files"` CSVDelimiter string `gorm:"column:csv_delimiter"` // LinksNewTab opens links to other sites in a new tab (Links tab, on // by default); NofollowDomains and SponsoredDomains list the domains // whose links always get nofollow or sponsored, one per line, // subdomains included (D-83). LinksNewTab bool `gorm:"column:links_new_tab"` NofollowDomains string `gorm:"column:nofollow_domains"` SponsoredDomains string `gorm:"column:sponsored_domains"` // RoutesReportedAt is the time of the newest route report (D-33), // written by the report only. RoutesReportedAt *time.Time `gorm:"column:routes_reported_at"` CreatedAt *time.Time `gorm:"column:created_at"` UpdatedAt *time.Time `gorm:"column:updated_at"` } func (Settings) TableName() string { return "golem15_seo_settings" } // MorphName names the settings row for translations. func (Settings) MorphName() string { return `Golem15\SEO\Models\Settings` } // Fillable lists the form's columns. func (Settings) Fillable() []string { return []string{ "site_name", "twitter_site", "title_template", "home_template", "default_description", "default_og_image", "query_params", "noindex_paths", "search_paths", "url_host", "url_slash", "url_lowercase", "verify_google", "verify_bing", "verify_yandex", "verify_other", "verify_files", "links_new_tab", "nofollow_domains", "sponsored_domains", } } // Rules is empty: the settings controller checks Problems before a save. func (Settings) Rules() map[string]string { return map[string]string{} } // DefaultSettings is what the resolver reads when the row is missing. func DefaultSettings() Settings { return Settings{ ID: 1, TitleTemplate: DefaultTitleTemplate, HomeTemplate: DefaultHomeTemplate, QueryParams: lagoon.Jsonable[[]QueryParam]{Data: DefaultQueryParams(), Valid: true}, URLHost: HostKeep, URLSlash: SlashKeep, LinksNewTab: true, } } // Params returns the query-parameter list. func (s Settings) Params() []QueryParam { return s.QueryParams.Get() } // OtherMeta returns the extra verification meta tags. func (s Settings) OtherMeta() []VerifyMeta { return s.VerifyOther.Get() } // Files returns the verification files. func (s Settings) Files() []VerifyFile { return s.VerifyFiles.Get() } // verifyFilename is the allowed form of a verification file name. var verifyFilename = regexp.MustCompile(`^[A-Za-z0-9._-]+\.(html|txt|xml)$`) // placeholderPattern finds %name% placeholders in a template. var placeholderPattern = regexp.MustCompile(`%[A-Za-z0-9_]*%`) // KnownPlaceholders are the placeholders a template may use. var KnownPlaceholders = []string{"%title%", "%description%", "%site%"} // UnknownPlaceholder returns the first placeholder of tpl that is not // known, "" when every one is. func UnknownPlaceholder(tpl string) string { for _, found := range placeholderPattern.FindAllString(tpl, -1) { known := false for _, k := range KnownPlaceholders { if found == k { known = true break } } if !known { return found } } return "" } // Problem is one field refusal: a phrase key and its :name parameters. // The admin controllers translate it in the request locale. type Problem struct { Key string Params map[string]string } // TemplateProblems refuses a template with an unknown placeholder. func TemplateProblems(tpl string) []Problem { if p := UnknownPlaceholder(tpl); p != "" { return []Problem{{Key: errTemplateUnknown, Params: map[string]string{"placeholder": p}}} } return nil } // Problems lists the settings refusals per field: templates with an unknown // placeholder, and verification file names that are malformed or used // twice (verify_files..filename). func (s Settings) Problems() map[string][]Problem { out := map[string][]Problem{} for field, tpl := range map[string]string{"title_template": s.TitleTemplate, "home_template": s.HomeTemplate} { if p := TemplateProblems(tpl); p != nil { out[field] = p } } seen := map[string]bool{} for i, f := range s.Files() { name := strings.TrimSpace(f.Filename) key := fmt.Sprintf("verify_files.%d.filename", i) switch { case !verifyFilename.MatchString(name): out[key] = []Problem{{Key: errVerifyFilenameInvalid}} case seen[strings.ToLower(name)]: out[key] = []Problem{{Key: errVerifyFilenameTaken}} } seen[strings.ToLower(name)] = true } for field, list := range map[string]string{"nofollow_domains": s.NofollowDomains, "sponsored_domains": s.SponsoredDomains} { if p := DomainProblems(list); p != nil { out[field] = p } } return out } // domainLabel is one label of a host name: letters (any script, written // in lowercase), digits and inner hyphens, at most 63 bytes. func domainLabel(label string) bool { if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return false } for _, r := range label { if r != '-' && !unicode.IsDigit(r) && !(unicode.IsLetter(r) && !unicode.IsUpper(r)) { return false } } return true } // ValidDomain reports whether d is a host name with at least two labels, // without a scheme, port, path or trailing dot. func ValidDomain(d string) bool { if d == "" || len(d) > 253 { return false } labels := strings.Split(d, ".") if len(labels) < 2 { return false } for _, l := range labels { if !domainLabel(l) { return false } } return true } // DomainLines splits a domain list into its trimmed, lowercased, // non-empty lines, in order and each once. func DomainLines(list string) []string { out := []string{} seen := map[string]bool{} for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") { d := strings.ToLower(strings.TrimSpace(line)) if d == "" || seen[d] { continue } seen[d] = true out = append(out, d) } return out } // NormalizeDomains rewrites a domain list as DomainLines, one per line. func NormalizeDomains(list string) string { return strings.Join(DomainLines(list), "\n") } // DomainProblems refuses the first line of list that is not a domain // (settings.domain_invalid with its line number and text). func DomainProblems(list string) []Problem { n := 0 for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") { n++ d := strings.ToLower(strings.TrimSpace(line)) if d == "" || ValidDomain(d) { continue } return []Problem{{Key: errDomainInvalid, Params: map[string]string{"line": strconv.Itoa(n), "value": strings.TrimSpace(line)}}} } return nil } func init() { Register(Settings{}) }