Files
sm-seo-plugin/models/settings.go
Jakub Zych 6203aec2bc feat: auto-link phrases in the resolve answer (tracer)
- golem15_seo_autolinks (migration 202610280007) and the settings column
  autolink_max (Links tab, 0 to 20, default 3)
- classes/autolinks.go: checked save and delete with history, ForPage
- ok resolve answers carry autolinks for the page's locale without
  disabled phrases and links to the page itself, and autolink_max
2026-11-04 16:22:23 +01:00

592 lines
22 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 models
import (
"fmt"
"net/url"
"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"
errValueInvalid = "golem15.seo::lang.api.value_invalid"
errSameAsInvalid = "golem15.seo::lang.settings.same_as_invalid"
errURLInvalid = "golem15.seo::lang.settings.url_invalid"
errEmailInvalid = "golem15.seo::lang.settings.email_invalid"
errLocalPerson = "golem15.seo::lang.settings.local_person"
errLocalAddress = "golem15.seo::lang.settings.local_address_required"
errTimeInvalid = "golem15.seo::lang.settings.time_invalid"
errHoursOrder = "golem15.seo::lang.settings.hours_order"
errCountryInvalid = "golem15.seo::lang.settings.country_invalid"
errCoordinateInvalid = "golem15.seo::lang.settings.coordinate_invalid"
errPageSegmentInvalid = "golem15.seo::lang.settings.page_segment_invalid"
)
// PagePlaceholders are the placeholders a page suffix may use (D-41).
var PagePlaceholders = []string{"%page%", "%pages%"}
// SuffixProblems refuses a page suffix with a placeholder other than
// %page% and %pages%.
func SuffixProblems(suffix string) []Problem {
for _, found := range placeholderPattern.FindAllString(suffix, -1) {
if !oneOfList(found, PagePlaceholders) {
return []Problem{{Key: errTemplateUnknown, Params: map[string]string{"placeholder": found}}}
}
}
return nil
}
// PaginationProblems lists the refusals of the Pagination tab: the form,
// a segment outside [a-z0-9-] and unknown suffix placeholders.
func (s Settings) PaginationProblems() map[string][]Problem {
out := map[string][]Problem{}
if s.PageForm != "" && !oneOfList(s.PageForm, []string{PageFormQuery, PageFormPath}) {
out["page_form"] = []Problem{{Key: errValueInvalid}}
}
if seg := strings.TrimSpace(s.PageSegment); seg != "" && !pageSegment.MatchString(seg) {
out["page_segment"] = []Problem{{Key: errPageSegmentInvalid}}
}
for field, v := range map[string]string{"page_title_suffix": s.PageTitleSuffix, "page_description_suffix": s.PageDescriptionSuffix} {
if p := SuffixProblems(v); p != nil {
out[field] = p
}
}
return out
}
// pageSegment is a valid page path segment.
var pageSegment = regexp.MustCompile(`^[a-z0-9-]+$`)
// LocalTypes are the LocalBusiness types the settings offer (UI-SPEC C20).
var LocalTypes = []string{
"LocalBusiness", "ProfessionalService", "Store", "FoodEstablishment",
"HealthAndBeautyBusiness", "MedicalBusiness", "LegalService",
"HomeAndConstructionBusiness", "AutomotiveBusiness", "FinancialService",
}
// Days are the day codes of the opening hours, Monday first.
var Days = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
// 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"`
}
// The publisher types (D-45) and the page address forms (D-40).
const (
PublisherOrganization = "organization"
PublisherPerson = "person"
PageFormQuery = "query"
PageFormPath = "path"
DefaultLocalType = "LocalBusiness"
)
// OpeningHours is one opening-hours row of a local business: a day (mon
// … sun) and its opening and closing time (24-hour HH:MM).
type OpeningHours struct {
Day string `json:"day"`
Opens string `json:"opens"`
Closes string `json:"closes"`
}
// 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"`
// AutolinkMax is the most phrases of SEO › Auto-links linked in one
// text (Links tab, 0 to 20, 3 by default); 0 turns auto-linking off
// (D-82).
AutolinkMax int `gorm:"column:autolink_max"`
// BreadcrumbHome is the home crumb's name (translatable; empty: the
// per-locale default copy).
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
// PageForm is the paginated address form, query (?page=N) or path
// (/<segment>/N); PageSegment the path segment, translatable per
// locale; the suffixes are added on page 2 and later (D-40, D-41).
PageForm string `gorm:"column:page_form"`
PageSegment string `gorm:"column:page_segment"`
PageTitleSuffix string `gorm:"column:page_title_suffix"`
PageDescriptionSuffix string `gorm:"column:page_description_suffix"`
// The publisher of the site (D-45): organization or person, its name
// (translatable; empty: the site name), logo or photo, website, email,
// phone and sameAs profiles (one https URL per line).
PublisherType string `gorm:"column:publisher_type"`
PublisherName string `gorm:"column:publisher_name"`
PublisherLogo string `gorm:"column:publisher_logo"`
PublisherURL string `gorm:"column:publisher_url"`
PublisherEmail string `gorm:"column:publisher_email"`
PublisherPhone string `gorm:"column:publisher_phone"`
PublisherSameAs string `gorm:"column:publisher_same_as"`
// The local business data of an organisation publisher (D-45).
LocalEnabled bool `gorm:"column:local_enabled"`
LocalType string `gorm:"column:local_type"`
LocalPriceRange string `gorm:"column:local_price_range"`
LocalStreet string `gorm:"column:local_street"`
LocalPostalCode string `gorm:"column:local_postal_code"`
LocalCity string `gorm:"column:local_city"`
LocalRegion string `gorm:"column:local_region"`
LocalCountry string `gorm:"column:local_country"`
LocalLatitude string `gorm:"column:local_latitude"`
LocalLongitude string `gorm:"column:local_longitude"`
LocalHours lagoon.Jsonable[[]OpeningHours] `gorm:"column:local_hours"`
// The Site audit tab (D-14, UI-SPEC C20): the automatic crawl (off,
// daily or weekly at night), pages per crawl (10 to 50 000), the pause
// between requests in milliseconds (100 to 5000), the crawls kept (1 to
// 50) and the generic link texts, one per line.
CrawlSchedule string `gorm:"column:crawl_schedule"`
CrawlMaxPages int `gorm:"column:crawl_max_pages"`
CrawlDelay int `gorm:"column:crawl_delay"`
CrawlKeep int `gorm:"column:crawl_keep"`
GenericAnchors string `gorm:"column:generic_anchors"`
// 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", "breadcrumb_home", "publisher_type",
"publisher_name", "publisher_logo", "publisher_url", "publisher_email",
"publisher_phone", "publisher_same_as", "local_enabled", "local_type",
"local_price_range", "local_street", "local_postal_code", "local_city",
"local_region", "local_country", "local_latitude", "local_longitude",
"local_hours", "page_form", "page_segment", "page_title_suffix",
"page_description_suffix", "crawl_schedule", "crawl_max_pages",
"crawl_delay", "crawl_keep", "generic_anchors", "autolink_max",
}
}
// 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,
AutolinkMax: DefaultAutolinkMax,
PageForm: PageFormQuery,
PublisherType: PublisherOrganization,
LocalType: DefaultLocalType,
CrawlSchedule: CrawlOff,
CrawlMaxPages: DefaultCrawlMaxPages,
CrawlDelay: DefaultCrawlDelay,
CrawlKeep: DefaultCrawlKeep,
GenericAnchors: DefaultGenericAnchors,
}
}
// The automatic crawl settings (crawl_schedule).
const (
CrawlOff = "off"
CrawlDaily = "daily"
CrawlWeekly = "weekly"
)
// The Site audit defaults and ranges (UI-SPEC C20).
const (
DefaultCrawlMaxPages = 5000
DefaultCrawlDelay = 250
DefaultCrawlKeep = 10
MinCrawlMaxPages = 10
MaxCrawlMaxPages = 50_000
MinCrawlDelay = 100
MaxCrawlDelay = 5000
MinCrawlKeep = 1
MaxCrawlKeep = 50
)
// DefaultGenericAnchors is the seed of the generic link texts: the en and
// pl lists of UI-SPEC C27, one per line.
const DefaultGenericAnchors = "click here\nhere\nmore\nread more\nlearn more\nthis link\nlink\nkliknij tutaj\ntutaj\ntu\nwięcej\nczytaj więcej\nczytaj dalej\nten link"
// AuditProblems refuses Site audit values and the Links tab's
// autolink_max outside their ranges (lagoon::validation.between.numeric,
// UI-SPEC C20) and an unknown schedule. The settings screen and the API check it on save; Problems
// leaves it out, so a partial Settings value checks its templates only.
func (s Settings) AuditProblems() map[string][]Problem {
out := map[string][]Problem{}
between := func(field string, v, min, max int) {
if v < min || v > max {
out[field] = append(out[field], Problem{Key: "lagoon::validation.between.numeric", Params: map[string]string{
"attribute": strings.ReplaceAll(field, "_", " "), "min": strconv.Itoa(min), "max": strconv.Itoa(max),
}})
}
}
between("crawl_max_pages", s.CrawlMaxPages, MinCrawlMaxPages, MaxCrawlMaxPages)
between("crawl_delay", s.CrawlDelay, MinCrawlDelay, MaxCrawlDelay)
between("crawl_keep", s.CrawlKeep, MinCrawlKeep, MaxCrawlKeep)
between("autolink_max", s.AutolinkMax, MinAutolinkMax, MaxAutolinkMax)
switch s.CrawlSchedule {
case CrawlOff, CrawlDaily, CrawlWeekly:
default:
out["crawl_schedule"] = append(out["crawl_schedule"], Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
return out
}
// Hours returns the local business's opening hours.
func (s Settings) Hours() []OpeningHours { return s.LocalHours.Get() }
// 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.<i>.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
}
}
for field, list := range s.PaginationProblems() {
out[field] = append(out[field], list...)
}
for field, list := range s.PublisherProblems() {
out[field] = append(out[field], list...)
}
return out
}
// timePattern is a 24-hour HH:MM time.
var timePattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
// countryPattern is a two-letter country code.
var countryPattern = regexp.MustCompile(`^[A-Za-z]{2}$`)
// HTTPSURL reports whether s is an absolute https URL with a host.
func HTTPSURL(s string) bool {
u, err := url.Parse(strings.TrimSpace(s))
return err == nil && u.Scheme == "https" && u.Host != "" && !strings.ContainsAny(s, " \t\n<>\"")
}
// oneOfList reports whether v is one of list.
func oneOfList(v string, list []string) bool {
for _, item := range list {
if v == item {
return true
}
}
return false
}
// PublisherProblems lists the refusals of the Publisher and Local
// business tabs (UI-SPEC C20, D-45, T-33-25): the publisher type, https
// sameAs lines (with the line number), the website, the email, a person
// as a local business, the business's address, country, coordinates and
// opening hours (local_hours.<i>.opens / .closes).
func (s Settings) PublisherProblems() map[string][]Problem {
out := map[string][]Problem{}
add := func(field string, p Problem) { out[field] = append(out[field], p) }
if s.PublisherType != "" && !oneOfList(s.PublisherType, []string{PublisherOrganization, PublisherPerson}) {
add("publisher_type", Problem{Key: errValueInvalid})
}
n := 0
for _, line := range strings.Split(strings.ReplaceAll(s.PublisherSameAs, "\r\n", "\n"), "\n") {
n++
if v := strings.TrimSpace(line); v != "" && !HTTPSURL(v) {
add("publisher_same_as", Problem{Key: errSameAsInvalid, Params: map[string]string{"line": strconv.Itoa(n)}})
break
}
}
if v := strings.TrimSpace(s.PublisherURL); v != "" && !HTTPSURL(v) {
add("publisher_url", Problem{Key: errURLInvalid})
}
if v := strings.TrimSpace(s.PublisherEmail); v != "" {
local, domain, ok := strings.Cut(v, "@")
if !ok || local == "" || !strings.Contains(domain, ".") || strings.ContainsAny(v, " <>\"") {
add("publisher_email", Problem{Key: errEmailInvalid})
}
}
if !s.LocalEnabled {
return out
}
if s.PublisherType == PublisherPerson {
add("local_enabled", Problem{Key: errLocalPerson})
}
if s.LocalType != "" && !oneOfList(s.LocalType, LocalTypes) {
add("local_type", Problem{Key: errValueInvalid})
}
if strings.TrimSpace(s.LocalStreet) == "" || strings.TrimSpace(s.LocalPostalCode) == "" || strings.TrimSpace(s.LocalCity) == "" {
for field, v := range map[string]string{"local_street": s.LocalStreet, "local_postal_code": s.LocalPostalCode, "local_city": s.LocalCity} {
if strings.TrimSpace(v) == "" {
add(field, Problem{Key: errLocalAddress})
}
}
}
if v := strings.TrimSpace(s.LocalCountry); v != "" && !countryPattern.MatchString(v) {
add("local_country", Problem{Key: errCountryInvalid})
}
coordinate := func(field, v string, limit float64) {
if v = strings.TrimSpace(v); v == "" {
return
}
f, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64)
if err != nil || f < -limit || f > limit {
add(field, Problem{Key: errCoordinateInvalid})
}
}
coordinate("local_latitude", s.LocalLatitude, 90)
coordinate("local_longitude", s.LocalLongitude, 180)
for i, h := range s.Hours() {
base := fmt.Sprintf("local_hours.%d.", i)
if !oneOfList(h.Day, Days) {
add(base+"day", Problem{Key: errValueInvalid})
}
okOpens, okCloses := timePattern.MatchString(h.Opens), timePattern.MatchString(h.Closes)
if !okOpens {
add(base+"opens", Problem{Key: errTimeInvalid})
}
if !okCloses {
add(base+"closes", Problem{Key: errTimeInvalid})
}
if okOpens && okCloses && h.Closes <= h.Opens {
add(base+"closes", Problem{Key: errHoursOrder})
}
}
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{})
}