Files
sm-seo-plugin/controllers/api/settings.go
Jakub Zych 49d3879954 feat: links settings and outbound rules in resolve
- migration 202610280003: settings links_new_tab (default on),
  nofollow_domains and sponsored_domains; robots llms_enabled and
  llms_intro (used by the llms.txt commit)
- SEO settings Links tab: the new-tab switch and the two domain lists,
  one domain per line, lowercased, refused with settings.domain_invalid
  naming the line; writable through seo_update_settings
- the resolve answer of an ok page carries outbound {new_tab,
  nofollow_domains, sponsored_domains}; no existing key changes
2026-11-04 13:26:31 +01:00

124 lines
6.2 KiB
Go

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 ;"`
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
})
}