Files
sm-seo-plugin/classes/rules.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

123 lines
3.4 KiB
Go

package classes
import (
"context"
"errors"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ErrNoRecord is returned for an id that names no row.
var ErrNoRecord = errors.New("golem15.seo: record not found")
// LoadRule returns rule id; ErrNoRecord when there is none.
func LoadRule(db *gorm.DB, id uint) (models.Rule, error) {
var rows []models.Rule
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Rule{}, err
}
if len(rows) == 0 {
return models.Rule{}, ErrNoRecord
}
return rows[0], nil
}
// SaveRule validates and stores r inside tx and records it in history: the
// address is trimmed and the match normalised, robots values are brought
// to the C5 grammar, and a canonical and the social image must be a path
// or an http(s) URL. Field problems are refused with a *ProblemsError.
func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) error {
if r == nil {
return errors.New("golem15.seo: no rule")
}
r.URL = strings.TrimSpace(r.URL)
r.Match = models.NormalizeMatch(r.Match)
problems := r.Problems(tx)
if v := strings.TrimSpace(r.Robots); v != "" {
normal, err := NormalizeRobots(v)
if err != nil {
problems["robots"] = append(problems["robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
}
r.Robots = normal
}
if v := strings.TrimSpace(r.Canonical); v != "" && !ValidURLRef(v) {
problems["canonical"] = append(problems["canonical"], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
}
r.OGImage = strings.TrimSpace(r.OGImage)
if !ValidImageURL(r.OGImage) {
problems["og_image"] = append(problems["og_image"], models.Problem{Key: errImageURL})
}
if len(problems) > 0 {
return &ProblemsError{Problems: problems}
}
now := time.Now().UTC()
var before map[string]any
if r.ID == 0 {
r.CreatedAt, r.UpdatedAt = &now, &now
if err := tx.Create(r).Error; err != nil {
return err
}
} else {
old, err := LoadRule(tx, r.ID)
if err != nil {
return err
}
before = RuleValues(old)
r.CreatedAt, r.UpdatedAt = old.CreatedAt, &now
if err := tx.Save(r).Error; err != nil {
return err
}
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectRule,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
Before: before,
After: RuleValues(*r),
})
}
// DeleteRule deletes r inside tx and records it in history.
func (s *Service) DeleteRule(ctx context.Context, tx *gorm.DB, r models.Rule) error {
if err := tx.Delete(&models.Rule{}, r.ID).Error; err != nil {
return err
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectRule,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
Before: RuleValues(r),
})
}
// SetRuleValue writes one value column of a rule; an unknown column is
// ignored.
func SetRuleValue(r *models.Rule, column, value string) {
switch column {
case "url":
r.URL = value
case "match":
r.Match = value
case "note":
r.Note = value
case "title":
r.Title = value
case "description":
r.Description = value
case "canonical":
r.Canonical = value
case "robots":
r.Robots = value
case "og_title":
r.OGTitle = value
case "og_description":
r.OGDescription = value
case "og_image":
r.OGImage = value
}
}