Files
sm-seo-plugin/controllers/api/settings.go
Jakub Zych fc53d93b9d feat: site audit settings and the nightly crawl
- Site audit tab (UI-SPEC C20): crawl_schedule, crawl_max_pages
  (10-50 000), crawl_delay (100-5000 ms), crawl_keep (1-50) and
  generic_anchors seeded with the en and pl lists; ranges refuse with
  lagoon::validation.between on the screen and through the API
- the crawler reads its limits and generic anchors from the settings;
  crawls beyond crawl_keep are deleted with their pages and issues
- seo:crawl [--scheduled] and the nightly schedule entry at 02:30
  (P33-40): off does nothing, weekly runs on Mondays in app.timezone
- TestCrawlSchedule
2026-11-04 15:41:54 +01:00

152 lines
11 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 api
import (
"context"
"encoding/json"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// UpdateSettingsInput is the body of PUT /_seo/api/v1/settings: only the
// fields sent change.
type UpdateSettingsInput struct {
SiteName *string `json:"site_name,omitempty" jsonschema:"the site name (%site% in templates)"`
TwitterSite *string `json:"twitter_site,omitempty" jsonschema:"the site's X/Twitter handle"`
TitleTemplate *string `json:"title_template,omitempty" jsonschema:"the title template: %title%, %description% and %site%"`
HomeTemplate *string `json:"home_template,omitempty" jsonschema:"the home page title template"`
DefaultDescription *string `json:"default_description,omitempty" jsonschema:"the description of pages without one"`
DefaultOGImage *string `json:"default_og_image,omitempty" jsonschema:"the social image of pages without one"`
QueryParams *[]models.QueryParam `json:"query_params,omitempty" jsonschema:"the query parameter list: name (a trailing * matches a prefix), kind content, filter or tracking, and an optional path pattern"`
NoindexPaths *string `json:"noindex_paths,omitempty" jsonschema:"path patterns never indexed, one per line"`
SearchPaths *string `json:"search_paths,omitempty" jsonschema:"search result path patterns (noindex,follow), one per line"`
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"`
VerifyOther *[]models.VerifyMeta `json:"verify_other,omitempty" jsonschema:"other verification meta tags (name, content)"`
VerifyFiles *[]models.VerifyFile `json:"verify_files,omitempty" jsonschema:"verification files served from the site root (filename, body)"`
CSVDelimiter *string `json:"csv_delimiter,omitempty" jsonschema:"the import and export delimiter: , or ;"`
BreadcrumbHome *string `json:"breadcrumb_home,omitempty" jsonschema:"the home page name in breadcrumb trails and BreadcrumbList (default locale); empty: Home / Strona główna"`
PublisherType *string `json:"publisher_type,omitempty" jsonschema:"who publishes the site in the JSON-LD of every page: organization or person"`
PublisherName *string `json:"publisher_name,omitempty" jsonschema:"the publisher name (default locale); empty: the site name"`
PublisherLogo *string `json:"publisher_logo,omitempty" jsonschema:"the organisation logo (at least 112x112 px) or the person's photo: a URL or a site path"`
PublisherURL *string `json:"publisher_url,omitempty" jsonschema:"the publisher website, an https URL; empty: the site address"`
PublisherEmail *string `json:"publisher_email,omitempty" jsonschema:"the publisher contact email"`
PublisherPhone *string `json:"publisher_phone,omitempty" jsonschema:"the publisher phone with the country code, e.g. +48 600 000 000"`
PublisherSameAs *string `json:"publisher_same_as,omitempty" jsonschema:"profiles elsewhere (sameAs), one https URL per line"`
LocalEnabled *bool `json:"local_enabled,omitempty" jsonschema:"publish the organisation as a local business (not for a person)"`
LocalType *string `json:"local_type,omitempty" jsonschema:"the local business type: LocalBusiness, ProfessionalService, Store, FoodEstablishment, HealthAndBeautyBusiness, MedicalBusiness, LegalService, HomeAndConstructionBusiness, AutomotiveBusiness or FinancialService"`
LocalPriceRange *string `json:"local_price_range,omitempty" jsonschema:"the price range, e.g. 50–200 zł or $$"`
LocalStreet *string `json:"local_street,omitempty" jsonschema:"street and number (required for a local business)"`
LocalPostalCode *string `json:"local_postal_code,omitempty" jsonschema:"postal code (required for a local business)"`
LocalCity *string `json:"local_city,omitempty" jsonschema:"city (required for a local business)"`
LocalRegion *string `json:"local_region,omitempty" jsonschema:"region"`
LocalCountry *string `json:"local_country,omitempty" jsonschema:"two-letter country code, e.g. PL"`
LocalLatitude *string `json:"local_latitude,omitempty" jsonschema:"latitude in decimal degrees (-90 to 90)"`
LocalLongitude *string `json:"local_longitude,omitempty" jsonschema:"longitude in decimal degrees (-180 to 180)"`
PageForm *string `json:"page_form,omitempty" jsonschema:"the paginated address form: query (/blog?page=2) or path (/blog/<segment>/2); the other form, ?page=1 and /<segment>/1 answer 301"`
PageSegment *string `json:"page_segment,omitempty" jsonschema:"the path form's segment in the default locale, lowercase letters, digits and hyphens (default strona for pl, page for en)"`
PageTitleSuffix *string `json:"page_title_suffix,omitempty" jsonschema:"added after the page title on page 2 and later (default locale); placeholders %page% and %pages%"`
PageDescriptionSuffix *string `json:"page_description_suffix,omitempty" jsonschema:"added at the end of the meta description on page 2 and later (default locale); placeholders %page% and %pages%"`
CrawlSchedule *string `json:"crawl_schedule,omitempty" jsonschema:"the automatic crawl at night: off, daily or weekly (Mondays)"`
CrawlMaxPages *int `json:"crawl_max_pages,omitempty" jsonschema:"pages per crawl, 10 to 50000 (default 5000)"`
CrawlDelay *int `json:"crawl_delay,omitempty" jsonschema:"the pause between two crawler requests in milliseconds, 100 to 5000 (default 250); at most two requests run at a time"`
CrawlKeep *int `json:"crawl_keep,omitempty" jsonschema:"crawls kept, 1 to 50 (default 10); older crawls are deleted with their pages and issues"`
GenericAnchors *string `json:"generic_anchors,omitempty" jsonschema:"generic link texts, one per line; the crawl reports links whose whole text is one of them, ignoring letter case"`
LocalHours *[]models.OpeningHours `json:"local_hours,omitempty" jsonschema:"opening hours rows: day (mon … sun), opens and closes in 24-hour HH:MM; two rows split a day, a day without rows is closed"`
Types map[string]classes.TypeSettingWrite `json:"types,omitempty" jsonschema:"per content type key: inject (show the SEO tab), title_template, description_template, robots"`
}
// settingsJSON is the settings in an answer: the values and, per content
// type, its label and settings.
func settingsJSON(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Settings) (map[string]any, error) {
stored, err := classes.LoadTypeSettings(db)
if err != nil {
return nil, err
}
types := map[string]any{}
if reg := svc.Registry(); reg != nil {
for _, typ := range reg.ContentTypes() {
ts, ok := stored[typ.Key]
if !ok {
ts = models.DefaultTypeSetting(typ.Key)
}
v := classes.TypeSettingValues(ts)
v["label"] = svc.PhraseCtx(ctx, typ.Label, nil)
types[typ.Key] = v
}
}
return map[string]any{"settings": classes.SettingsValues(row), "types": types}, nil
}
// ShowSettings serves GET /_seo/api/v1/settings.
func ShowSettings(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
row, err := classes.LoadSettings(db)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}
// UpdateSettings serves PUT /_seo/api/v1/settings: the fields sent change,
// with the checks of the settings screen; 422 validation_failed names the
// refused fields.
func UpdateSettings(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
raw := map[string]json.RawMessage{}
if err := decode(r, maxBody, &raw); err != nil {
return 0, nil, err
}
in := classes.SettingsWrite{Values: map[string]any{}}
for key, value := range raw {
if key == "types" {
if err := json.Unmarshal(value, &in.Types); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
continue
}
var v any
if err := json.Unmarshal(value, &v); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
in.Values[key] = v
}
var row models.Settings
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, err = svc.SaveSettings(ctx, tx, in)
return err
})
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}