package models import ( "fmt" "regexp" "strings" "time" "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" ) // 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"` // 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", } } // 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, } } // 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 } return out } func init() { Register(Settings{}) }