Two concurrent undos of one entry both passed the already-undone check and both restored. Undo and UndoBatch now read the entries FOR UPDATE, and the final mark is conditional on undone_at IS NULL, refusing with already_undone when another undo got there first.
552 lines
16 KiB
Go
552 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
|
|
}
|
|
}
|
|
_, _, 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)
|
|
}
|
|
}
|