diff --git a/classes/resolver.go b/classes/resolver.go index ad02882..f660476 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -57,11 +57,14 @@ type Result struct { Status int `json:"status"` // Location is the absolute address a redirect answer sends the visitor // to; absent on every other action. - Location string `json:"location,omitempty"` - URL string `json:"url"` - Locale string `json:"locale"` - Staging bool `json:"staging"` - SEO *SEO `json:"seo"` + Location string `json:"location,omitempty"` + URL string `json:"url"` + Locale string `json:"locale"` + Staging bool `json:"staging"` + SEO *SEO `json:"seo"` + // Outbound is how the layer renders links to other sites (P33-16), + // present on ok answers. + Outbound *Outbound `json:"outbound,omitempty"` Sources map[string]string `json:"-"` // TypeKey and RecordID name the record behind the URL, "" when none. TypeKey string `json:"-"` @@ -80,6 +83,35 @@ type SEO struct { Verification []MetaTag `json:"verification"` } +// Outbound holds the settings' Links rules for links to other sites +// (D-83): open them in a new tab, and the domains (subdomains included) +// whose links always get nofollow or sponsored. +type Outbound struct { + NewTab bool `json:"new_tab"` + NofollowDomains []string `json:"nofollow_domains"` + SponsoredDomains []string `json:"sponsored_domains"` +} + +// OutboundOf is the Links rules of the settings. +func OutboundOf(s models.Settings) *Outbound { + return &Outbound{ + NewTab: s.LinksNewTab, + NofollowDomains: validDomains(s.NofollowDomains), + SponsoredDomains: validDomains(s.SponsoredDomains), + } +} + +// validDomains lists the valid domains of a stored list. +func validDomains(list string) []string { + out := []string{} + for _, d := range models.DomainLines(list) { + if models.ValidDomain(d) { + out = append(out, d) + } + } + return out +} + // OpenGraph holds the og: values. type OpenGraph struct { Title string `json:"title"` @@ -646,6 +678,7 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S } res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo + res.Outbound = OutboundOf(settings) return res, nil } diff --git a/classes/settings_write.go b/classes/settings_write.go index 5780d32..c56761e 100644 --- a/classes/settings_write.go +++ b/classes/settings_write.go @@ -37,7 +37,8 @@ var SettingsColumns = []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", + "url_lowercase", "links_new_tab", "nofollow_domains", + "sponsored_domains", "verify_google", "verify_bing", "verify_yandex", "verify_other", "verify_files", "csv_delimiter", } @@ -57,6 +58,9 @@ func SettingsValues(st models.Settings) map[string]any { "url_host": st.URLHost, "url_slash": st.URLSlash, "url_lowercase": st.URLLowercase, + "links_new_tab": st.LinksNewTab, + "nofollow_domains": st.NofollowDomains, + "sponsored_domains": st.SponsoredDomains, "verify_google": st.VerifyGoogle, "verify_bing": st.VerifyBing, "verify_yandex": st.VerifyYandex, @@ -119,12 +123,22 @@ func SetSettingsValue(st *models.Settings, column string, v any) error { st.VerifyYandex, err = text() case "csv_delimiter": st.CSVDelimiter, err = text() + case "nofollow_domains": + st.NofollowDomains, err = text() + case "sponsored_domains": + st.SponsoredDomains, err = text() case "url_lowercase": b, ok := v.(bool) if !ok { return fmt.Errorf("golem15.seo: settings %s is not a bool", column) } st.URLLowercase = b + case "links_new_tab": + b, ok := v.(bool) + if !ok { + return fmt.Errorf("golem15.seo: settings %s is not a bool", column) + } + st.LinksNewTab = b case "query_params": var out []models.QueryParam if err = list(&out); err == nil { @@ -256,6 +270,8 @@ func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrit if len(problems) > 0 { return models.Settings{}, &ProblemsError{Problems: problems} } + row.NofollowDomains = models.NormalizeDomains(row.NofollowDomains) + row.SponsoredDomains = models.NormalizeDomains(row.SponsoredDomains) now := time.Now().UTC() row.UpdatedAt = &now if err := tx.Save(&row).Error; err != nil { diff --git a/controllers/api/settings.go b/controllers/api/settings.go index 98b0405..35cf545 100644 --- a/controllers/api/settings.go +++ b/controllers/api/settings.go @@ -25,6 +25,9 @@ type UpdateSettingsInput struct { URLHost *string `json:"url_host,omitempty" jsonschema:"keep, www or bare"` URLSlash *string `json:"url_slash,omitempty" jsonschema:"keep, add or remove a trailing slash"` URLLowercase *bool `json:"url_lowercase,omitempty" jsonschema:"lowercase the ASCII letters of addresses"` + LinksNewTab *bool `json:"links_new_tab,omitempty" jsonschema:"open links to other sites in a new tab (rel=noopener, announced to screen readers); default true"` + NofollowDomains *string `json:"nofollow_domains,omitempty" jsonschema:"domains whose links always get nofollow, one per line, e.g. example.com (subdomains included; no https:// and no path)"` + SponsoredDomains *string `json:"sponsored_domains,omitempty" jsonschema:"domains whose links always get sponsored, one per line (subdomains included)"` VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"` VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"` VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"` diff --git a/controllers/settings.go b/controllers/settings.go index 4eee58c..018ac31 100644 --- a/controllers/settings.go +++ b/controllers/settings.go @@ -63,7 +63,12 @@ func (c settingsAdminController) FormBeforeUpdate(ctx context.Context, model any if !ok || s == nil { return nil } - return problemsError(ctx, translator(c.app), s.Problems()) + if err := problemsError(ctx, translator(c.app), s.Problems()); err != nil { + return err + } + s.NofollowDomains = models.NormalizeDomains(s.NofollowDomains) + s.SponsoredDomains = models.NormalizeDomains(s.SponsoredDomains) + return nil } // FormBeforeCreate checks a created row like an update. diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 19f2128..97ca97e 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -75,6 +75,13 @@ settings: tab_indexing: Indexing tab_urls: Addresses tab_verification: Verification + tab_links: Links + links_new_tab: Open links to other sites in a new tab + links_new_tab_comment: "On: links to other sites open in a new tab with rel=noopener, and screen readers hear that they do. Off: they open in the same tab. Their nofollow, sponsored and ugc attributes apply either way." + nofollow_domains: Domains that always get nofollow + sponsored_domains: Domains that always get sponsored + domains_comment: One domain per line, e.g. example.com (subdomains included). + domain_invalid: "Line :line: “:value” is not a domain. Write it without https:// and without a path." site_name: Site name site_name_comment: Replaces %site% in title templates. title_template: Title template diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index f5756bb..49aacc1 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -77,6 +77,13 @@ settings: tab_indexing: Indeksowanie tab_urls: Adresy tab_verification: Weryfikacja + tab_links: Linki + links_new_tab: Otwieraj linki do innych witryn w nowej karcie + links_new_tab_comment: "Włączone: linki do innych witryn otwierają się w nowej karcie z rel=noopener, a czytniki ekranu o tym informują. Wyłączone: otwierają się w tej samej karcie. Atrybuty nofollow, sponsored i ugc działają w obu przypadkach." + nofollow_domains: Domeny zawsze z nofollow + sponsored_domains: Domeny zawsze ze sponsored + domains_comment: Jedna domena w wierszu, np. example.com (razem z subdomenami). + domain_invalid: "Wiersz :line: „:value” nie jest domeną. Wpisz ją bez https:// i bez ścieżki." site_name: Nazwa witryny site_name_comment: Zastępuje %site% w szablonach tytułów. title_template: Szablon tytułu diff --git a/models/settings.go b/models/settings.go index f5907b7..4234832 100644 --- a/models/settings.go +++ b/models/settings.go @@ -3,8 +3,10 @@ package models import ( "fmt" "regexp" + "strconv" "strings" "time" + "unicode" "git.golem15.com/golem15/summercms/modules/lagoon" ) @@ -37,6 +39,7 @@ 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 @@ -95,6 +98,13 @@ type Settings struct { 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"` @@ -114,7 +124,8 @@ func (Settings) Fillable() []string { "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", + "verify_other", "verify_files", "links_new_tab", "nofollow_domains", + "sponsored_domains", } } @@ -130,6 +141,7 @@ func DefaultSettings() Settings { QueryParams: lagoon.Jsonable[[]QueryParam]{Data: DefaultQueryParams(), Valid: true}, URLHost: HostKeep, URLSlash: SlashKeep, + LinksNewTab: true, } } @@ -206,9 +218,82 @@ func (s Settings) Problems() map[string][]Problem { } 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{}) } diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 536c71e..356d430 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -3,8 +3,8 @@ # =================================== # # SEO settings (UI-SPEC C11): General, one tab per content type (added by -# the plugin's form extension after General), Indexing, Addresses and -# Verification. +# the plugin's form extension after General), Indexing, Addresses, Links +# (UI-SPEC C20) and Verification. fields: _staging: @@ -139,6 +139,30 @@ fields: default: false tab: golem15.seo::lang.settings.tab_urls + links_new_tab: + label: golem15.seo::lang.settings.links_new_tab + comment: golem15.seo::lang.settings.links_new_tab_comment + type: switch + span: right + default: true + tab: golem15.seo::lang.settings.tab_links + + nofollow_domains: + label: golem15.seo::lang.settings.nofollow_domains + comment: golem15.seo::lang.settings.domains_comment + type: textarea + size: small + span: left + tab: golem15.seo::lang.settings.tab_links + + sponsored_domains: + label: golem15.seo::lang.settings.sponsored_domains + comment: golem15.seo::lang.settings.domains_comment + type: textarea + size: small + span: right + tab: golem15.seo::lang.settings.tab_links + verify_google: label: golem15.seo::lang.settings.verify_google comment: golem15.seo::lang.settings.verify_code_comment diff --git a/registry_test.go b/registry_test.go index 0e6e43b..bc3e062 100644 --- a/registry_test.go +++ b/registry_test.go @@ -330,3 +330,57 @@ func TestRouteReport(t *testing.T) { t.Fatalf("missing=only after the page is reported again = %s", again) } } + +// outboundOf reads the resolve answer's outbound rules. +func outboundOf(t *testing.T, got map[string]any) map[string]any { + t.Helper() + out, ok := got["outbound"].(map[string]any) + if !ok { + t.Fatalf("resolve answer without outbound: %v", got) + } + return out +} + +// TestSettingsLinks covers the Links tab (UI-SPEC C20, D-83, P33-16): the +// new-tab switch on by default, the domain lists with their line-numbered +// refusal, the MCP-writable keys and outbound in the resolve answer. +func TestSettingsLinks(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + findField(t, env.formSchema(t, token, "golem15/seo/settings"), "nofollow_domains") + env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true}) + + out := outboundOf(t, env.resolve(t, "/pages/about")) + if out["new_tab"] != true || len(out["nofollow_domains"].([]any)) != 0 || len(out["sponsored_domains"].([]any)) != 0 { + t.Fatalf("default outbound = %v, want new_tab true and no domains", out) + } + if got := env.resolve(t, "/pages/missing"); got["outbound"] != nil { + t.Fatalf("a not_found answer carries outbound: %v", got) + } + + rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "example.com\nhttps://x.pl/a"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `Line 2: “https://x.pl/a” is not a domain.`) { + t.Fatalf("admin save of a bad domain = %d %s, want 422 naming line 2", rec.Code, rec.Body.String()) + } + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "Example.com\n\n shop.example.org "}) + var stored string + if err := env.db.Raw(`SELECT nofollow_domains FROM golem15_seo_settings WHERE id = 1`).Scan(&stored).Error; err != nil || stored != "example.com\nshop.example.org" { + t.Fatalf("stored nofollow_domains = %q err=%v", stored, err) + } + + api := "/_seo/api/v1/settings" + rec = env.call(t, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl\nnot a domain"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "sponsored_domains") || !strings.Contains(rec.Body.String(), "Line 2") { + t.Fatalf("API save of a bad domain = %d %s", rec.Code, rec.Body.String()) + } + env.expect(t, http.StatusOK, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl", "links_new_tab": false}) + show := env.expect(t, http.StatusOK, http.MethodGet, api, token, nil).Body.String() + if !jsonContains(show, `"links_new_tab":false`) || !jsonContains(show, `"sponsored_domains":"partner.pl"`) { + t.Fatalf("settings = %s", show) + } + out = outboundOf(t, env.resolve(t, "/pages/about")) + raw, _ := json.Marshal(out) + if string(raw) != `{"new_tab":false,"nofollow_domains":["example.com","shop.example.org"],"sponsored_domains":["partner.pl"]}` { + t.Fatalf("outbound = %s", raw) + } +} diff --git a/settings_test.go b/settings_test.go index 124259d..94a9761 100644 --- a/settings_test.go +++ b/settings_test.go @@ -31,7 +31,7 @@ func TestSettingsTypeTabs(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) fields := env.formSchema(t, token, "golem15/seo/settings") - if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Verification"; got != want { + if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Links | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } findField(t, fields, "type_acme_page_inject") @@ -81,7 +81,7 @@ func TestSettingsTypeTabs(t *testing.T) { t.Run("no other content types", func(t *testing.T) { env := bootPlugins(t, []string{"golem15.seo"}) token := env.admin(t, true) - if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Verification"; got != want { + if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Links | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } }) diff --git a/updates/202610280003_add_golem15_seo_links_and_llms.go b/updates/202610280003_add_golem15_seo_links_and_llms.go new file mode 100644 index 0000000..3ac73d7 --- /dev/null +++ b/updates/202610280003_add_golem15_seo_links_and_llms.go @@ -0,0 +1,37 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addLinksAndLLMS adds the SEO settings' Links tab (links_new_tab, on by +// default; nofollow_domains and sponsored_domains, one domain per line) +// and the robots.txt screen's llms.txt switch (llms_enabled, off by +// default) and introduction (llms_intro). +var addLinksAndLLMS = []*gormigrate.Migration{ + { + ID: "202610280003_add_golem15_seo_links_and_llms", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec(`ALTER TABLE golem15_seo_settings + ADD COLUMN links_new_tab BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN nofollow_domains TEXT NOT NULL DEFAULT '', + ADD COLUMN sponsored_domains TEXT NOT NULL DEFAULT ''`).Error; err != nil { + return err + } + return tx.Exec(`ALTER TABLE golem15_seo_robots + ADD COLUMN llms_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN llms_intro TEXT NOT NULL DEFAULT ''`).Error + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec(`ALTER TABLE golem15_seo_robots DROP COLUMN IF EXISTS llms_enabled, DROP COLUMN IF EXISTS llms_intro`).Error; err != nil { + return err + } + return tx.Exec(`ALTER TABLE golem15_seo_settings DROP COLUMN IF EXISTS links_new_tab, DROP COLUMN IF EXISTS nofollow_domains, DROP COLUMN IF EXISTS sponsored_domains`).Error + }, + }, +} + +func init() { + Register(addLinksAndLLMS...) +}