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" ) // 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). func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) { rev, err := LoadRevision(tx, 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.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 } return tx.Model(&models.Revision{}).Where("id = ?", rev.ID).Updates(updates).Error } // 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) } }