Files
sm-seo-plugin/classes/undo.go
Jakub Zych 742a5b930b feat: site endpoint, llms.txt and the sitemap index in robots.txt
- GET /_seo/api/v1/site (public, noindex): site_url, default_locale,
  locales, unprefixed_locale, links.new_tab, html_sitemap (from the
  sitemap plugin's Listing), routes of every content type, root_files
  (verification files and the IndexNow key file) and llms
- GET /llms.txt: with llms_enabled (robots.txt screen, AI bots tab, off by
  default) and in production, the indexable pages from the sitemaps per
  group under the site name and llms_intro (else the default
  description); otherwise 404; seo_update_robots takes both fields
- robots.txt names the sitemap plugin's index unless
  golem15.seo.robots_sitemaps is set; the config default is now a comment
2026-11-04 13:32:12 +01:00

559 lines
16 KiB
Go

package classes
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The undo refusals (D-13, T-32-27).
const (
UndoStale = "undo_stale"
UndoLoop = "undo_loop"
UndoGone = "undo_gone"
UndoDone = "already_undone"
UndoUnsupported = "undo_unsupported"
)
// UndoRefusal refuses an undo: Code is one of the Undo* constants, Key the
// phrase key of its message and Params the message's parameters. Nothing
// was undone.
type UndoRefusal struct {
Code string
Key string
Params map[string]string
}
func (e *UndoRefusal) Error() string { return "golem15.seo: " + e.Code }
// LoadRevision returns history entry id; ErrNoRecord when there is none.
func LoadRevision(db *gorm.DB, id uint) (models.Revision, error) {
var rows []models.Revision
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Revision{}, err
}
if len(rows) == 0 {
return models.Revision{}, ErrNoRecord
}
return rows[0], nil
}
// Undoable reports whether a history entry can be offered an undo: not
// undone yet and of a kind undo restores.
func Undoable(r models.Revision) bool {
if r.UndoneAt != nil {
return false
}
switch r.SubjectKind {
case models.SubjectEntry, models.SubjectRule, models.SubjectRedirect, models.SubjectRobots, models.SubjectSettings, models.SubjectNotFound:
return true
}
return false
}
// Undo restores the values before history entry id inside tx (D-13): the
// undo is itself recorded (via undo) and the entry is marked undone. It
// is refused with an *UndoRefusal when the entry is undone already, when a
// later change touched the same values (undo_stale, naming who and when),
// when the restore would create a redirect loop (undo_loop) or when the
// record it belongs to is gone (undo_gone).
//
// The entry is locked (FOR UPDATE) until tx ends, so a second undo of the
// same entry waits and is then refused as already undone (WR-11).
func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) {
rev, err := LoadRevision(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id)
if err != nil {
return nil, err
}
if rev.UndoneAt != nil {
return nil, &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"}
}
if err := s.undoOne(WithVia(ctx, models.ViaUndo), tx, rev); err != nil {
return nil, err
}
return []uint{rev.ID}, nil
}
// UndoBatch undoes every change of batch that is not undone yet, newest
// first, inside tx: all of them or none (D-13). ErrNoRecord when the batch
// has nothing left to undo.
func (s *Service) UndoBatch(ctx context.Context, tx *gorm.DB, batch string) ([]uint, error) {
var revs []models.Revision
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
return nil, err
}
if len(revs) == 0 {
return nil, ErrNoRecord
}
ctx = WithVia(ctx, models.ViaUndo)
ids := make([]uint, 0, len(revs))
for _, rev := range revs {
if err := s.undoOne(ctx, tx, rev); err != nil {
return nil, err
}
ids = append(ids, rev.ID)
}
return ids, nil
}
// BatchSize counts the changes of batch that are not undone yet.
func BatchSize(db *gorm.DB, batch string) (int64, error) {
var n int64
err := db.Model(&models.Revision{}).Where("batch_id = ? AND undone_at IS NULL", batch).Count(&n).Error
return n, err
}
// jsonNorm brings a value to its JSON form, so a stored history value
// and a current one compare equal when they encode the same.
func jsonNorm(v any) any {
raw, err := json.Marshal(plainValue(v))
if err != nil {
return fmt.Sprint(v)
}
var out any
if json.Unmarshal(raw, &out) != nil {
return string(raw)
}
if s, ok := out.(string); ok {
return strings.TrimSpace(s)
}
return out
}
// sameValue reports whether two values encode the same; nil and "" are
// the same empty value.
func sameValue(a, b any) bool {
na, nb := jsonNorm(a), jsonNorm(b)
if na == nil {
na = ""
}
if nb == nil {
nb = ""
}
return reflect.DeepEqual(na, nb)
}
// subjectState is the current values of a history subject; exists is
// false when the object is gone.
type subjectState struct {
values map[string]any
exists bool
// recordGone is true for an entry whose record no longer exists.
recordGone bool
typ pact.MenuItemType
model string
recordID string
locale string
}
// currentState reads the current values of rev's subject.
func (s *Service) currentState(ctx context.Context, tx *gorm.DB, rev models.Revision) (subjectState, error) {
var st subjectState
switch rev.SubjectKind {
case models.SubjectEntry:
parts := strings.SplitN(rev.SubjectID, "|", 3)
if len(parts) != 3 {
return st, &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
}
st.model, st.recordID, st.locale = parts[0], parts[1], parts[2]
reg := s.Registry()
typ, ok := pact.MenuItemType{}, false
if reg != nil {
typ, ok = reg.ContentTypeForModel(st.model)
}
if !ok || typ.Content == nil {
st.recordGone = true
return st, nil
}
st.typ = typ
rec, err := reg.Describe(ctx, typ.Key, st.recordID, s.UnprefixedLocale())
if err != nil {
return st, err
}
if rec == nil {
st.recordGone = true
return st, nil
}
e, found, err := LoadEntry(tx, st.model, st.recordID, st.locale)
if err != nil {
return st, err
}
st.exists = true
st.values = map[string]any{}
for _, c := range EntryColumns {
v := ""
if found {
v = EntryValue(e, c)
}
st.values[c] = v
}
case models.SubjectRule:
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
r, err := LoadRule(tx, uint(id))
if errors.Is(err, ErrNoRecord) {
return st, nil
}
if err != nil {
return st, err
}
st.exists, st.values = true, RuleValues(r)
case models.SubjectRedirect:
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
r, err := LoadRedirect(tx, uint(id))
if errors.Is(err, ErrNoRecord) {
return st, nil
}
if err != nil {
return st, err
}
st.exists, st.values = true, RedirectValues(r)
case models.SubjectRobots:
r, err := LoadRobots(tx)
if err != nil {
return st, err
}
st.exists, st.values = true, RobotsValues(r)
case models.SubjectSettings:
if key, ok := strings.CutPrefix(rev.SubjectID, "type:"); ok {
ts, err := LoadTypeSetting(tx, key)
if err != nil {
return st, err
}
st.exists, st.values = true, TypeSettingValues(ts)
} else {
row, err := LoadSettings(tx)
if err != nil {
return st, err
}
st.exists, st.values = true, SettingsValues(row)
}
case models.SubjectNotFound:
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
n, err := LoadNotFound(tx, uint(id))
if errors.Is(err, ErrNoRecord) {
return st, nil
}
if err != nil {
return st, err
}
st.exists, st.values = true, NotFoundValues(n)
default:
return st, &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
}
return st, nil
}
// staleRefusal names the newest later change of field on rev's subject.
func (s *Service) staleRefusal(ctx context.Context, tx *gorm.DB, rev models.Revision, field string) error {
author, when := "—", ""
var later []models.Revision
if err := tx.Where("subject_kind = ? AND subject_id = ? AND id > ?", rev.SubjectKind, rev.SubjectID, rev.ID).Order("id DESC").Find(&later).Error; err != nil {
return err
}
for _, l := range later {
if _, ok := l.After[field]; ok || len(l.After) == 0 {
if l.AuthorLabel != "" {
author = l.AuthorLabel
}
when = l.CreatedAt.UTC().Format("2006-01-02 15:04")
break
}
}
return &UndoRefusal{Code: UndoStale, Key: "golem15.seo::lang.history.undo_stale", Params: map[string]string{
"field": s.fieldLabel(ctx, field), "author": author, "time": when,
}}
}
// fieldLabel is the label of a history field in the request locale.
func (s *Service) fieldLabel(ctx context.Context, key string) string {
base, locale, _ := strings.Cut(key, ".")
for _, p := range models.RuleProperties {
if p.Column == base {
label := s.PhraseCtx(ctx, p.Label, nil)
if locale != "" {
label += " (" + locale + ")"
}
return label
}
}
return key
}
// undoOne restores the values before rev and marks rev undone.
func (s *Service) undoOne(ctx context.Context, tx *gorm.DB, rev models.Revision) error {
st, err := s.currentState(ctx, tx, rev)
if err != nil {
return err
}
if rev.SubjectKind == models.SubjectEntry && st.recordGone {
label := s.PhraseCtx(ctx, "golem15.seo::lang.history.record", nil)
if st.typ.Label != "" {
label = s.PhraseCtx(ctx, st.typ.Label, nil)
}
return &UndoRefusal{Code: UndoGone, Key: "golem15.seo::lang.history.undo_gone", Params: map[string]string{"type": label}}
}
created := len(rev.Before) == 0 && len(rev.After) > 0
deleted := len(rev.After) == 0 && len(rev.Before) > 0
if deleted {
if st.exists && rev.SubjectKind != models.SubjectEntry {
return s.staleRefusal(ctx, tx, rev, firstKey(rev.Before))
}
} else {
if !st.exists {
return &UndoRefusal{Code: UndoGone, Key: "golem15.seo::lang.history.undo_gone", Params: map[string]string{"type": s.PhraseCtx(ctx, kindLabels[rev.SubjectKind], nil)}}
}
keys := make([]string, 0, len(rev.After))
for k := range rev.After {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
cur, ok := st.values[k]
if !ok {
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
}
if !sameValue(cur, rev.After[k]) {
return s.staleRefusal(ctx, tx, rev, k)
}
}
}
if err := s.restore(ctx, tx, rev, st, created, deleted); err != nil {
var loop *LoopError
if errors.As(err, &loop) {
return &UndoRefusal{Code: UndoLoop, Key: "golem15.seo::lang.history.undo_loop", Params: map[string]string{"path": loop.Check.PathText()}}
}
return err
}
now := time.Now().UTC()
updates := map[string]any{"undone_at": now}
if p, ok := bouncer.User(ctx); ok && p.ID != 0 {
updates["undone_by_id"] = p.ID
} else if p, ok := mcpPrincipal(ctx); ok && p.ID != 0 {
updates["undone_by_id"] = p.ID
}
marked := tx.Model(&models.Revision{}).Where("id = ? AND undone_at IS NULL", rev.ID).Updates(updates)
if marked.Error != nil {
return marked.Error
}
if marked.RowsAffected == 0 {
return &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"}
}
return nil
}
// kindLabels are the phrase keys of the history subjects in the undo_gone
// refusal.
var kindLabels = map[string]string{
models.SubjectEntry: "golem15.seo::lang.history.record",
models.SubjectRule: "golem15.seo::lang.history.kind_rule",
models.SubjectRedirect: "golem15.seo::lang.history.kind_redirect",
models.SubjectRobots: "golem15.seo::lang.history.kind_robots",
models.SubjectSettings: "golem15.seo::lang.history.kind_settings",
models.SubjectNotFound: "golem15.seo::lang.history.kind_notfound",
}
// firstKey is the first key of m in order, "" for none.
func firstKey(m map[string]any) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
return ""
}
return keys[0]
}
// text reads a history value as text.
func text(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case bool:
return strconv.FormatBool(t)
}
return fmt.Sprint(v)
}
// restore writes the values before rev through the service, so the undo
// is checked (loops) and recorded like any write.
func (s *Service) restore(ctx context.Context, tx *gorm.DB, rev models.Revision, st subjectState, created, deleted bool) error {
switch rev.SubjectKind {
case models.SubjectEntry:
values := RecordValues{}
for k := range rev.After {
values[k] = map[string]string{st.locale: text(rev.Before[k])}
}
if deleted {
for k, v := range rev.Before {
values[k] = map[string]string{st.locale: text(v)}
}
}
_, _, err := s.SaveRecord(ctx, tx, RecordWrite{Type: st.typ, ID: st.recordID, Values: values})
return err
case models.SubjectRule:
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
switch {
case created:
r, err := LoadRule(tx, uint(id))
if err != nil {
return err
}
return s.DeleteRule(ctx, tx, r)
case deleted:
r := models.Rule{}
for k, v := range rev.Before {
SetRuleValue(&r, k, text(v))
}
return s.SaveRule(ctx, tx, &r)
}
r, err := LoadRule(tx, uint(id))
if err != nil {
return err
}
for k := range rev.After {
SetRuleValue(&r, k, text(rev.Before[k]))
}
return s.SaveRule(ctx, tx, &r)
case models.SubjectRedirect:
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
switch {
case created:
r, err := LoadRedirect(tx, uint(id))
if err != nil {
return err
}
return s.DeleteRedirect(ctx, tx, r)
case deleted:
r := models.NewRedirect()
for k, v := range rev.Before {
setRedirectValue(&r, k, v)
}
_, err := s.SaveRedirect(ctx, tx, &r)
return err
}
r, err := LoadRedirect(tx, uint(id))
if err != nil {
return err
}
for k := range rev.After {
setRedirectValue(&r, k, rev.Before[k])
}
_, err = s.SaveRedirect(ctx, tx, &r)
return err
case models.SubjectRobots:
var w RobotsWrite
if v, ok := rev.Before["content"]; ok {
content := text(v)
w.Content = &content
}
if bots, ok := rev.Before["bots"].(map[string]any); ok {
w.Bots = map[string]bool{}
for token, on := range bots {
b, _ := on.(bool)
w.Bots[token] = b
}
}
if v, ok := rev.Before["llms_enabled"].(bool); ok {
w.LlmsEnabled = &v
}
if v, ok := rev.Before["llms_intro"]; ok {
intro := text(v)
w.LlmsIntro = &intro
}
_, _, err := s.SaveRobots(ctx, tx, w)
return err
case models.SubjectSettings:
if key, ok := strings.CutPrefix(rev.SubjectID, "type:"); ok {
w := TypeSettingWrite{}
for k := range rev.After {
v := rev.Before[k]
switch k {
case "inject":
b, _ := v.(bool)
w.Inject = &b
case "title_template":
t := text(v)
w.TitleTemplate = &t
case "description_template":
t := text(v)
w.DescriptionTemplate = &t
case "robots":
t := text(v)
w.Robots = &t
}
}
_, err := s.SaveSettings(ctx, tx, SettingsWrite{Types: map[string]TypeSettingWrite{key: w}})
return err
}
values := map[string]any{}
for k := range rev.After {
values[k] = rev.Before[k]
}
_, err := s.SaveSettings(ctx, tx, SettingsWrite{Values: values})
return err
case models.SubjectNotFound:
if !deleted {
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
}
now := time.Now().UTC()
row := models.NotFound{URL: text(rev.Before["url"]), Referer: text(rev.Before["referer"]), FirstSeen: &now, LastHit: &now}
if h, ok := rev.Before["hits"].(float64); ok {
row.Hits = int64(h)
}
if err := tx.Create(&row).Error; err != nil {
return err
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectNotFound,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_notfound", map[string]string{"url": row.URL}),
After: NotFoundValues(row),
})
}
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
}
// setRedirectValue writes one history value onto a redirect.
func setRedirectValue(r *models.Redirect, key string, v any) {
switch key {
case "source":
r.Source = text(v)
case "match":
r.Match = text(v)
case "target":
r.Target = text(v)
case "status":
if n, err := strconv.Atoi(text(v)); err == nil {
r.Status = models.RedirectStatus(n)
}
case "query":
r.Query = text(v)
case "ignore_case":
r.IgnoreCase, _ = v.(bool)
case "trailing_slash":
r.TrailingSlash, _ = v.(bool)
case "origin":
r.Origin = text(v)
case "note":
r.Note = text(v)
}
}