Files
sm-seo-plugin/classes/owner.go
Jakub Zych c8e0961740 fix: social images are validated on the API, MCP, batch and import paths (WR-16)
Only the admin seoimage field refused values that are not a path or an
http(s) URL; SetURLSEO, SaveRule and SaveSettings stored javascript:,
data: or protocol-relative values, which /resolve then served as
og:image. All three now refuse them with image.url_invalid.
2026-10-27 15:35:02 +01:00

197 lines
6.6 KiB
Go

package classes
import (
"context"
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Where a change addressed by URL is written (D-11).
const (
WrittenToRecord = "record"
WrittenToRule = "rule"
)
// Owner is the owner of a URL's SEO (D-11, P-25): the record behind the
// URL in the matched locale, or an exact URL rule. URL is the address in
// the site's form (normalised path with its query); RuleID is the rule's
// id once it exists. Published is true for a published record.
type Owner struct {
WrittenTo string `json:"written_to"`
URL string `json:"url"`
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
Locale string `json:"locale,omitempty"`
RuleID uint `json:"rule_id,omitempty"`
Published bool `json:"-"`
}
// FindOwner resolves the owner of rawURL (a path or an absolute URL of the
// site) inside db: the URL is normalised with the site's address rules,
// then the signpost patterns (MatchPath) name the record behind it. An
// exact match without a query is the record's SEO in the matched locale;
// anything else, or forceRule, is an exact URL rule on the normalised
// address. ErrBadURL refuses an input that is not a path or http(s) URL.
func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) {
path, _, err := parseTarget(rawURL)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
settings, err := LoadSettings(db)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings))
address := joinQuery(norm.Path, norm.RawQuery)
if !forceRule && norm.RawQuery == "" {
if reg := s.Registry(); reg != nil {
unprefixed := s.UnprefixedLocale()
key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if match != nil && match.Exact {
typ, ok := reg.ContentType(key)
if ok && typ.Content != nil {
owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale}
if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil {
owner.Published = rec.Published
}
return owner, typ, nil
}
}
}
}
owner := Owner{WrittenTo: WrittenToRule, URL: address}
rule, found, err := ExactRule(db, address)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if found {
owner.RuleID = rule.ID
}
return owner, pact.MenuItemType{}, nil
}
// ExactRule returns the exact URL rule of address; found is false when
// there is none.
func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) {
var rows []models.Rule
if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil {
return models.Rule{}, false, err
}
if len(rows) == 0 {
return models.Rule{}, false, nil
}
return rows[0], true, nil
}
// URLSEOWrite is one change of a URL's SEO (D-11): the address, the
// locale of a record's entry (empty takes the locale the URL matched),
// the values to write (column -> value; an absent column is left as it
// is, an empty value inherits), and ForceRule to write a URL rule even
// when a record is behind the URL.
type URLSEOWrite struct {
URL string
Locale string
Values map[string]string
ForceRule bool
}
// URLSEOResult is where a URL change was written and the stored values
// of the owner after the write.
type URLSEOResult struct {
Owner
Values map[string]string `json:"values"`
}
// SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the
// record's SEO entry when a record is behind the URL, else an exact URL
// rule, created when missing. Robots values are brought to the C5 grammar,
// a canonical must be a path or an http(s) URL, and the focus keyphrase
// belongs to records only; problems are refused with a *ProblemsError.
// History records the write with the via of ctx.
func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) {
owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule)
if err != nil {
return URLSEOResult{}, err
}
values := map[string]string{}
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, column := range EntryColumns {
known[column] = true
}
for column, raw := range in.Values {
v := strings.TrimSpace(raw)
switch {
case !known[column]:
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "":
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"})
continue
case column == "robots" && v != "":
normal, err := NormalizeRobots(v)
if err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
continue
}
v = normal
case column == "canonical" && v != "" && !ValidURLRef(v):
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
continue
case column == "og_image" && !ValidImageURL(v):
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
continue
}
values[column] = v
}
if len(problems) > 0 {
return URLSEOResult{}, &ProblemsError{Problems: problems}
}
if owner.WrittenTo == WrittenToRecord {
locale := owner.Locale
if l := strings.TrimSpace(in.Locale); l != "" {
locale = l
}
owner.Locale = locale
write := RecordValues{}
for column, v := range values {
write[column] = map[string]string{locale: v}
}
_, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write})
if err != nil {
return URLSEOResult{}, err
}
out := map[string]string{}
e := after[locale]
for _, column := range EntryColumns {
out[column] = EntryValue(e, column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}
if owner.RuleID != 0 {
if rule, err = LoadRule(tx, owner.RuleID); err != nil {
return URLSEOResult{}, err
}
}
for column, v := range values {
SetRuleValue(&rule, column, v)
}
if err := s.SaveRule(ctx, tx, &rule); err != nil {
return URLSEOResult{}, err
}
owner.RuleID = rule.ID
out := map[string]string{}
for _, p := range models.RuleProperties {
out[p.Column] = rule.Value(p.Column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}