feat: batches with dry run, undo, and CSV import and export

- POST /_seo/api/v1/batch runs changes in one transaction: dry_run
  answers the diff and the conflicts (loops, chains, noindex on a
  published record, canonicals to redirected or noindex addresses) and
  rolls back; a refused change refuses the batch (D-12)
- undo of a change or a whole batch from History and through MCP, with
  the undo_stale, undo_loop and undo_gone refusals (D-13)
- CSV export and import of redirects, rules and records by URL: BOM,
  comma or semicolon, formula cells escaped, a dry-run preview, all or
  nothing with via import; the screen's apply token is HMAC-SHA256 over
  the file and a fingerprint of the data (transfer.stale)
- Import and export singleton with the seo_transfer widget action,
  History record actions undo and undo_batch, navigation entry
- MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv
- TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
This commit is contained in:
2026-10-27 12:35:26 +01:00
parent aa0c3bcacf
commit ab3b219fec
27 changed files with 3319 additions and 96 deletions

475
classes/batch.go Normal file
View File

@@ -0,0 +1,475 @@
package classes
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// MaxBatchChanges caps the changes of one batch.
const MaxBatchChanges = 2000
// The operations of a batch change.
const (
OpSetSEO = "set_seo"
OpCreateRedirect = "create_redirect"
OpUpdateRedirect = "update_redirect"
OpDeleteRedirect = "delete_redirect"
OpCreateRule = "create_rule"
OpUpdateRule = "update_rule"
OpDeleteRule = "delete_rule"
)
// The conflict kinds a batch reports (D-12).
const (
ConflictLoop = "redirect_loop"
ConflictChain = "redirect_chain"
ConflictNoindexPublished = "noindex_published"
ConflictCanonicalRedirected = "canonical_redirected"
ConflictCanonicalNoindex = "canonical_noindex"
)
// RedirectFields are the fields of a redirect write; a nil field is left
// as it is (or takes the default on a create).
type RedirectFields struct {
Source *string `json:"source,omitempty"`
Target *string `json:"target,omitempty"`
Status *int `json:"status,omitempty"`
Match *string `json:"match,omitempty"`
Query *string `json:"query,omitempty"`
IgnoreCase *bool `json:"ignore_case,omitempty"`
TrailingSlash *bool `json:"trailing_slash,omitempty"`
Note *string `json:"note,omitempty"`
}
// Apply writes the fields that are set onto r.
func (f RedirectFields) Apply(r *models.Redirect) {
if f.Source != nil {
r.Source = strings.TrimSpace(*f.Source)
}
if f.Target != nil {
r.Target = strings.TrimSpace(*f.Target)
}
if f.Status != nil {
r.Status = models.RedirectStatus(*f.Status)
}
if f.Match != nil {
r.Match = strings.TrimSpace(*f.Match)
}
if f.Query != nil {
r.Query = strings.TrimSpace(*f.Query)
}
if f.IgnoreCase != nil {
r.IgnoreCase = *f.IgnoreCase
}
if f.TrailingSlash != nil {
r.TrailingSlash = *f.TrailingSlash
}
if f.Note != nil {
r.Note = strings.TrimSpace(*f.Note)
}
}
// RuleFields are the fields of a URL rule write; a nil field is left as
// it is.
type RuleFields struct {
URL *string `json:"url,omitempty"`
Match *string `json:"match,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Canonical *string `json:"canonical,omitempty"`
Robots *string `json:"robots,omitempty"`
OGTitle *string `json:"og_title,omitempty"`
OGDescription *string `json:"og_description,omitempty"`
OGImage *string `json:"og_image,omitempty"`
Note *string `json:"note,omitempty"`
}
// Apply writes the fields that are set onto r.
func (f RuleFields) Apply(r *models.Rule) {
for column, v := range map[string]*string{
"url": f.URL, "match": f.Match, "title": f.Title, "description": f.Description,
"canonical": f.Canonical, "robots": f.Robots, "og_title": f.OGTitle,
"og_description": f.OGDescription, "og_image": f.OGImage, "note": f.Note,
} {
if v != nil {
SetRuleValue(r, column, strings.TrimSpace(*v))
}
}
}
// BatchChange is one change of a batch (D-12): set_seo addresses a page
// by URL (D-11); the redirect and rule operations create, update (by id)
// or delete (by id).
type BatchChange struct {
Op string `json:"op"`
ID uint `json:"id,omitempty"`
URL string `json:"url,omitempty"`
Locale string `json:"locale,omitempty"`
Values map[string]string `json:"values,omitempty"`
ForceRule bool `json:"force_rule,omitempty"`
Redirect *RedirectFields `json:"redirect,omitempty"`
Rule *RuleFields `json:"rule,omitempty"`
}
// DiffEntry is one changed object of a batch change: the history row the
// change wrote, its before and after values.
type DiffEntry struct {
Kind string `json:"kind"`
Subject string `json:"subject"`
What string `json:"what"`
Before map[string]any `json:"before"`
After map[string]any `json:"after"`
}
// Conflict is a finding of a batch (D-12): a loop refuses the batch, the
// other kinds are warnings.
type Conflict struct {
Change int `json:"change"`
Kind string `json:"kind"`
URL string `json:"url,omitempty"`
Path []string `json:"path,omitempty"`
Target string `json:"target,omitempty"`
Message string `json:"message"`
Refused bool `json:"refused,omitempty"`
}
// BatchItem is the outcome of one change: what it wrote (the diff), where
// a URL change went, its conflicts, and the refusal when it was refused.
type BatchItem struct {
Index int `json:"index"`
Op string `json:"op"`
Owner *Owner `json:"owner,omitempty"`
Diff []DiffEntry `json:"diff"`
Conflicts []Conflict `json:"conflicts,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Errors map[string][]string `json:"errors,omitempty"`
Path []string `json:"path,omitempty"`
}
// BatchResult is a batch's answer: the batch id its history rows carry,
// whether it was a dry run and whether it was applied, every change's
// outcome, all conflicts, and how many changes wrote something.
type BatchResult struct {
BatchID string `json:"batch_id"`
DryRun bool `json:"dry_run"`
Applied bool `json:"applied"`
Changes []BatchItem `json:"changes"`
Conflicts []Conflict `json:"conflicts"`
Count int `json:"count"`
}
// Refused returns the first refused change, nil when none was.
func (r BatchResult) Refused() *BatchItem {
for i := range r.Changes {
if r.Changes[i].Error != "" {
return &r.Changes[i]
}
}
return nil
}
// BatchRefusedError refuses a batch with a refused change: nothing was
// written (D-12, all-or-nothing).
type BatchRefusedError struct{ Result BatchResult }
func (e *BatchRefusedError) Error() string {
if item := e.Result.Refused(); item != nil {
return fmt.Sprintf("golem15.seo: batch change %d refused: %s", item.Index, item.Error)
}
return "golem15.seo: batch refused"
}
// ErrStale refuses an apply whose data changed after its preview.
var ErrStale = errors.New("golem15.seo: the data changed after the preview")
// errDryRun rolls a dry run back on purpose.
var errDryRun = errors.New("golem15.seo: dry run")
// BatchOptions are a batch's mode: DryRun rolls everything back after the
// diff and conflicts are collected; Via overrides the writer history
// records (import); BatchID names the batch (a new id when empty).
type BatchOptions struct {
DryRun bool
Via string
BatchID string
}
// NewBatchID returns a random batch id.
func NewBatchID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
return hex.EncodeToString(b[:])
}
// RunBatch runs changes as one batch (D-12, RESEARCH Pattern 8): every
// change goes through the service inside one transaction, each change's
// diff (the history rows it wrote) and conflicts are collected, and the
// conflicts that depend on the whole batch (a canonical pointing to a
// redirect another change creates) are checked once every change is in
// place. A dry run then rolls back on purpose; a refused change (a loop,
// an invalid value) rolls everything back and answers a
// *BatchRefusedError; otherwise the batch commits with one batch id on
// every history row.
func (s *Service) RunBatch(ctx context.Context, changes []BatchChange, opts BatchOptions) (BatchResult, error) {
return s.runBatch(ctx, opts, func(context.Context, *gorm.DB) ([]BatchChange, error) { return changes, nil })
}
// runBatch is RunBatch with the changes built inside the transaction
// (the import maps its rows against the data it applies to).
func (s *Service) runBatch(ctx context.Context, opts BatchOptions, build func(ctx context.Context, tx *gorm.DB) ([]BatchChange, error)) (BatchResult, error) {
gdb, err := s.GormDB()
if err != nil {
return BatchResult{}, err
}
res := BatchResult{BatchID: opts.BatchID, DryRun: opts.DryRun, Changes: []BatchItem{}, Conflicts: []Conflict{}}
if res.BatchID == "" {
res.BatchID = NewBatchID()
}
err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
ctx = WithTx(WithBatch(ctx, res.BatchID), tx)
if opts.Via != "" {
ctx = WithVia(ctx, opts.Via)
}
changes, err := build(ctx, tx)
if err != nil {
return err
}
if len(changes) > MaxBatchChanges {
return &ProblemsError{Problems: map[string][]models.Problem{"changes": {{Key: "golem15.seo::lang.api.batch_too_large", Params: map[string]string{"max": fmt.Sprint(MaxBatchChanges)}}}}}
}
for i, ch := range changes {
item, err := s.batchChange(ctx, tx, i, ch, res.BatchID)
if err != nil {
return err
}
res.Changes = append(res.Changes, item)
}
for i := range res.Changes {
late, err := s.batchConflicts(ctx, tx, i, changes[i], res.Changes[i])
if err != nil {
return err
}
res.Changes[i].Conflicts = append(res.Changes[i].Conflicts, late...)
}
for _, item := range res.Changes {
res.Conflicts = append(res.Conflicts, item.Conflicts...)
if len(item.Diff) > 0 {
res.Count++
}
}
if res.Refused() != nil {
return &BatchRefusedError{Result: res}
}
if opts.DryRun {
return errDryRun
}
return nil
})
switch {
case errors.Is(err, errDryRun):
return res, nil
case err != nil:
var refused *BatchRefusedError
if errors.As(err, &refused) {
refused.Result.Applied = false
}
return res, err
}
res.Applied = true
return res, nil
}
// batchChange runs one change under a savepoint, so a refused change
// leaves the transaction usable for the next one, and reads the history
// rows it wrote as its diff.
func (s *Service) batchChange(ctx context.Context, tx *gorm.DB, index int, ch BatchChange, batchID string) (BatchItem, error) {
item := BatchItem{Index: index, Op: ch.Op, Diff: []DiffEntry{}}
var last uint
if err := tx.Raw(`SELECT COALESCE(MAX(id), 0) FROM golem15_seo_revisions`).Scan(&last).Error; err != nil {
return item, err
}
point := fmt.Sprintf("seo_change_%d", index)
if err := tx.SavePoint(point).Error; err != nil {
return item, err
}
err := s.applyChange(ctx, tx, index, ch, &item)
if err != nil {
if rb := tx.RollbackTo(point).Error; rb != nil {
return item, rb
}
var loop *LoopError
var problems *ProblemsError
switch {
case errors.As(err, &loop):
item.Error, item.Path = ConflictLoop, loop.Check.Path
item.Message = s.LoopMessage(ctx, loop.Check)
item.Conflicts = append(item.Conflicts, Conflict{Change: index, Kind: ConflictLoop, Path: loop.Check.Path, Message: item.Message, Refused: true})
case errors.As(err, &problems):
item.Error = "validation_failed"
item.Errors = map[string][]string{}
var msgs []string
for field, list := range problems.Problems {
for _, p := range list {
msg := s.PhraseCtx(ctx, p.Key, p.Params)
item.Errors[field] = append(item.Errors[field], msg)
msgs = append(msgs, field+": "+msg)
}
}
item.Message = strings.Join(msgs, "; ")
case errors.Is(err, ErrNoRecord):
item.Error, item.Message = "not_found", s.PhraseCtx(ctx, "golem15.seo::lang.api.change_not_found", nil)
case errors.Is(err, ErrBadURL):
item.Error, item.Message = "bad_url", err.Error()
case errors.Is(err, errUnknownOp):
item.Error, item.Message = "unknown_op", s.PhraseCtx(ctx, "golem15.seo::lang.api.op_unknown", map[string]string{"op": ch.Op})
default:
return item, err
}
return item, nil
}
if err := tx.Exec("RELEASE SAVEPOINT " + point).Error; err != nil {
return item, err
}
var rows []models.Revision
if err := tx.Where("id > ? AND batch_id = ?", last, batchID).Order("id ASC").Find(&rows).Error; err != nil {
return item, err
}
for _, r := range rows {
item.Diff = append(item.Diff, DiffEntry{Kind: r.SubjectKind, Subject: r.SubjectID, What: r.What, Before: r.Before, After: r.After})
}
return item, nil
}
// errUnknownOp refuses a change with an unknown operation.
var errUnknownOp = errors.New("golem15.seo: unknown batch operation")
// applyChange runs one change through the service.
func (s *Service) applyChange(ctx context.Context, tx *gorm.DB, index int, ch BatchChange, item *BatchItem) error {
switch ch.Op {
case OpSetSEO:
res, err := s.SetURLSEO(ctx, tx, URLSEOWrite{URL: ch.URL, Locale: ch.Locale, Values: ch.Values, ForceRule: ch.ForceRule})
if err != nil {
return err
}
owner := res.Owner
item.Owner = &owner
return nil
case OpCreateRedirect, OpUpdateRedirect:
r := models.NewRedirect()
r.Origin = RedirectOrigin(ctx)
if ch.Op == OpUpdateRedirect {
var err error
if r, err = LoadRedirect(tx, ch.ID); err != nil {
return err
}
}
if ch.Redirect != nil {
ch.Redirect.Apply(&r)
}
check, err := s.SaveRedirect(ctx, tx, &r)
if err != nil {
return err
}
if check.Chain {
notice := ChainNotice(check)[0]
item.Conflicts = append(item.Conflicts, Conflict{Change: index, Kind: ConflictChain, URL: r.Source, Path: check.Path, Target: check.Final, Message: s.PhraseCtx(ctx, notice.Message, notice.Params)})
}
return nil
case OpDeleteRedirect:
r, err := LoadRedirect(tx, ch.ID)
if err != nil {
return err
}
return s.DeleteRedirect(ctx, tx, r)
case OpCreateRule, OpUpdateRule:
r := models.Rule{Match: models.MatchExact}
if ch.Op == OpUpdateRule {
var err error
if r, err = LoadRule(tx, ch.ID); err != nil {
return err
}
}
if ch.Rule != nil {
ch.Rule.Apply(&r)
}
return s.SaveRule(ctx, tx, &r)
case OpDeleteRule:
r, err := LoadRule(tx, ch.ID)
if err != nil {
return err
}
return s.DeleteRule(ctx, tx, r)
}
return errUnknownOp
}
// batchConflicts are the warnings that depend on the whole batch, checked
// once every change is in place: noindex on the address of a published
// record (the records the sitemaps list; Phase 33's sitemap filter
// refines it) and a canonical pointing to a redirected or noindex
// address.
func (s *Service) batchConflicts(ctx context.Context, tx *gorm.DB, index int, ch BatchChange, item BatchItem) ([]Conflict, error) {
if item.Error != "" {
return nil, nil
}
var out []Conflict
var robots, canonical, url string
switch ch.Op {
case OpSetSEO:
robots, canonical, url = ch.Values["robots"], ch.Values["canonical"], ch.URL
if item.Owner != nil {
url = item.Owner.URL
}
case OpCreateRule, OpUpdateRule:
if ch.Rule == nil {
return nil, nil
}
if ch.Rule.Robots != nil {
robots = *ch.Rule.Robots
}
if ch.Rule.Canonical != nil {
canonical = *ch.Rule.Canonical
}
if ch.Rule.URL != nil {
url = *ch.Rule.URL
}
default:
return nil, nil
}
if strings.Contains(strings.ToLower(robots), "noindex") && url != "" && !strings.Contains(url, "*") {
owner, _, err := s.FindOwner(ctx, tx, url, false)
if err != nil && !errors.Is(err, ErrBadURL) {
return nil, err
}
if err == nil && owner.WrittenTo == WrittenToRecord && owner.Published {
out = append(out, Conflict{Change: index, Kind: ConflictNoindexPublished, URL: owner.URL,
Message: s.PhraseCtx(ctx, "golem15.seo::lang.api.conflict_noindex_published", map[string]string{"url": owner.URL})})
}
}
if strings.TrimSpace(canonical) != "" {
kind, target, err := s.canonicalProblem(ctx, canonical)
if err != nil {
return nil, err
}
switch kind {
case IssueCanonicalRedirected:
out = append(out, Conflict{Change: index, Kind: ConflictCanonicalRedirected, URL: url, Target: target,
Message: s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_canonical", map[string]string{"target": target})})
case IssueCanonicalNoindex:
out = append(out, Conflict{Change: index, Kind: ConflictCanonicalNoindex, URL: url,
Message: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_noindex", map[string]string{"url": canonical})})
}
}
return out, nil
}

145
classes/csv.go Normal file
View File

@@ -0,0 +1,145 @@
package classes
import (
"bytes"
"encoding/csv"
"errors"
"io"
"strings"
)
// BOM is the UTF-8 byte order mark export files start with, so Excel
// opens Polish characters correctly; import strips it.
const BOM = "\ufeff"
// ErrUnreadable refuses a file that is not CSV with a header row
// (transfer.unreadable).
var ErrUnreadable = errors.New("golem15.seo: the file could not be read as CSV")
// CSVDelimiter returns the delimiter rune of a setting: ";" or ",", comma
// by default.
func CSVDelimiter(d string) rune {
if strings.TrimSpace(d) == ";" {
return ';'
}
return ','
}
// formulaStart reports whether a cell would be read as a formula by a
// spreadsheet: it starts with = + - @, a tab or a carriage return.
func formulaStart(s string) bool {
if s == "" {
return false
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return true
}
return false
}
// escapeCell prefixes a formula cell with an apostrophe (CSV formula
// injection, T-32-24).
func escapeCell(s string) string {
if formulaStart(s) {
return "'" + s
}
return s
}
// unescapeCell drops the apostrophe escapeCell added, so an exported file
// imports unchanged.
func unescapeCell(s string) string {
if strings.HasPrefix(s, "'") && formulaStart(s[1:]) {
return s[1:]
}
return s
}
// WriteCSV writes header and rows as UTF-8 with a BOM and the delimiter,
// every formula cell escaped.
func WriteCSV(header []string, rows [][]string, delimiter string) (string, error) {
var b bytes.Buffer
b.WriteString(BOM)
w := csv.NewWriter(&b)
w.Comma = CSVDelimiter(delimiter)
if err := w.Write(header); err != nil {
return "", err
}
for _, row := range rows {
cells := make([]string, len(row))
for i, c := range row {
cells[i] = escapeCell(c)
}
if err := w.Write(cells); err != nil {
return "", err
}
}
w.Flush()
return b.String(), w.Error()
}
// CSVRow is one data row of a file: its line number and its cells by
// column name.
type CSVRow struct {
Line int
Fields map[string]string
}
// ReadCSV reads a file with a header row: the BOM is stripped, the
// delimiter is the one given (detected from the header when empty), the
// column names are trimmed and lowercased and the apostrophe of escaped
// formula cells is dropped. Rows whose cells are all empty are skipped.
// A file that is not CSV, has no header or was read with the wrong
// delimiter answers ErrUnreadable.
func ReadCSV(data, delimiter string) ([]string, []CSVRow, error) {
data = strings.TrimPrefix(data, BOM)
if strings.TrimSpace(data) == "" {
return nil, nil, ErrUnreadable
}
first, _, _ := strings.Cut(data, "\n")
comma := CSVDelimiter(delimiter)
if strings.TrimSpace(delimiter) == "" && strings.Count(first, ";") > strings.Count(first, ",") {
comma = ';'
}
r := csv.NewReader(strings.NewReader(data))
r.Comma = comma
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return nil, nil, ErrUnreadable
}
for i, h := range header {
header[i] = strings.ToLower(strings.TrimSpace(h))
}
if len(header) == 1 && strings.ContainsAny(header[0], ",;") {
return nil, nil, ErrUnreadable
}
var rows []CSVRow
for {
rec, err := r.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, nil, ErrUnreadable
}
line, _ := r.FieldPos(0)
row := CSVRow{Line: line, Fields: map[string]string{}}
empty := true
for i, cell := range rec {
if i >= len(header) || header[i] == "" {
continue
}
cell = unescapeCell(strings.TrimSpace(cell))
if cell != "" {
empty = false
}
row.Fields[header[i]] = cell
}
if !empty {
rows = append(rows, row)
}
}
return header, rows, nil
}

View File

@@ -18,6 +18,21 @@ import (
type viaKey struct{}
type batchKey struct{}
// WithBatch marks ctx as part of batch id: every history row written
// through it carries the batch id, so the batch can be listed and undone
// as a whole (D-12, D-13).
func WithBatch(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, batchKey{}, id)
}
// BatchOf is the batch id of ctx, "" outside a batch.
func BatchOf(ctx context.Context) string {
id, _ := ctx.Value(batchKey{}).(string)
return id
}
// WithVia marks ctx as a write through via (import, auto, undo), which
// history records instead of admin or mcp.
func WithVia(ctx context.Context, via string) context.Context {
@@ -182,10 +197,14 @@ func (s *Service) author(ctx context.Context, db *gorm.DB, via string) (*uint, s
}
// Record appends one history row per change whose values differ, inside
// db (the caller's transaction): author and via from ctx, What as given,
// before and after of the changed fields only.
// db (the caller's transaction): author, via and batch from ctx, What as
// given, before and after of the changed fields only.
func (s *Service) Record(ctx context.Context, db *gorm.DB, changes ...Change) error {
via := ViaOf(ctx)
var batch *string
if id := BatchOf(ctx); id != "" {
batch = &id
}
var authorID *uint
var authorLabel string
authored := false
@@ -199,6 +218,7 @@ func (s *Service) Record(ctx context.Context, db *gorm.DB, changes ...Change) er
authored = true
}
row := models.Revision{
BatchID: batch,
AuthorID: authorID,
AuthorLabel: authorLabel,
Via: via,

802
classes/transfer.go Normal file
View File

@@ -0,0 +1,802 @@
package classes
import (
"bytes"
"compress/gzip"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// The datasets of import and export (P-27, UI-SPEC C13).
const (
DatasetRedirects = "redirects"
DatasetRules = "rules"
DatasetRecords = "records"
)
// The size caps of import and export (RESEARCH Pitfall 14, T-32-29).
const (
// AdminImportLimit is the largest file the import screen previews
// (UI-SPEC Q-8 assumption: 60 KB, under the 64 KiB action payload).
AdminImportLimit = 60 << 10
// MCPImportLimit is the largest file one MCP import takes; larger files
// are imported in chunks.
MCPImportLimit = 900 << 10
// ExportActionLimit is the largest export the screen downloads in one
// action answer (the action data is capped at 256 KiB).
ExportActionLimit = 250 << 10
// ExportPageRows is the number of rows of one export page through MCP.
ExportPageRows = 1000
// PreviewRowCap is the number of preview rows the screen shows.
PreviewRowCap = 200
)
// DatasetColumns are each dataset's columns in file order; the first one
// is required.
var DatasetColumns = map[string][]string{
DatasetRedirects: {"source", "match", "target", "status", "query", "ignore_case", "trailing_slash", "note"},
DatasetRules: {"url", "match", "title", "description", "canonical", "robots", "og_title", "og_description", "og_image", "note"},
DatasetRecords: {"url", "locale", "title", "description", "keyphrase", "canonical", "robots", "og_title", "og_description", "og_image"},
}
// TransferError refuses an import or export as a whole: Code is
// unreadable, missing_column, dataset_unknown, too_large or stale, Key the
// phrase key of its message.
type TransferError struct {
Code string
Key string
Params map[string]string
}
func (e *TransferError) Error() string { return "golem15.seo: transfer " + e.Code }
// ImportRefusedError refuses an apply while a row is refused; nothing was
// written.
type ImportRefusedError struct{ Result ImportResult }
func (e *ImportRefusedError) Error() string { return "golem15.seo: import refused" }
// Export is one export answer: the file name, the CSV text (UTF-8 with a
// BOM) and the page of the rows it holds.
type Export struct {
Dataset string `json:"dataset"`
Filename string `json:"filename"`
CSV string `json:"csv"`
Page int `json:"page"`
Pages int `json:"pages"`
Total int `json:"total"`
}
// boolCell writes a bool cell.
func boolCell(b bool) string {
if b {
return "1"
}
return "0"
}
// parseBool reads a bool cell: 1, true, yes, on, tak and their opposites.
func parseBool(s string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on", "tak", "y":
return true, true
case "0", "false", "no", "off", "nie", "n":
return false, true
}
return false, false
}
// datasetRows reads every row of a dataset in file order.
func datasetRows(db *gorm.DB, dataset string) ([][]string, error) {
var out [][]string
switch dataset {
case DatasetRedirects:
var rows []models.Redirect
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, []string{r.Source, r.Match, r.Target, strconv.Itoa(int(r.Status)), r.Query, boolCell(r.IgnoreCase), boolCell(r.TrailingSlash), r.Note})
}
case DatasetRules:
var rows []models.Rule
if err := db.Order("url ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, []string{r.URL, r.Match, r.Title, r.Description, r.Canonical, r.Robots, r.OGTitle, r.OGDescription, r.OGImage, r.Note})
}
case DatasetRecords:
var rows []models.Entry
if err := db.Where("url <> ''").Order("url ASC, locale ASC").Find(&rows).Error; err != nil {
return nil, err
}
for _, e := range rows {
out = append(out, []string{e.URL, e.Locale, e.Title, e.Description, e.Keyphrase, e.Canonical, e.Robots, e.OGTitle, e.OGDescription, e.OGImage})
}
default:
return nil, &TransferError{Code: "dataset_unknown", Key: "golem15.seo::lang.transfer.dataset_unknown"}
}
return out, nil
}
// Export writes one page of a dataset as CSV with the delimiter (D-13):
// UTF-8 with a BOM and formula cells prefixed with an apostrophe. perPage
// 0 exports every row.
func (s *Service) Export(ctx context.Context, dataset, delimiter string, page, perPage int) (Export, error) {
db, err := s.DB(ctx)
if err != nil {
return Export{}, err
}
rows, err := datasetRows(db, dataset)
if err != nil {
return Export{}, err
}
out := Export{Dataset: dataset, Total: len(rows), Page: 1, Pages: 1,
Filename: "seo-" + dataset + "-" + time.Now().UTC().Format("2006-01-02") + ".csv"}
if perPage > 0 {
out.Pages = (len(rows) + perPage - 1) / perPage
if out.Pages == 0 {
out.Pages = 1
}
if page < 1 {
page = 1
}
out.Page = page
start := (page - 1) * perPage
if start > len(rows) {
start = len(rows)
}
end := min(start+perPage, len(rows))
rows = rows[start:end]
}
out.CSV, err = WriteCSV(DatasetColumns[dataset], rows, delimiter)
return out, err
}
// ImportRequest is one import: the dataset, the file and its delimiter
// (detected from the header when empty), whether to apply, the writer
// history records, and for an apply from a preview token the fingerprint
// the preview saw.
type ImportRequest struct {
Dataset string
CSV string
Delimiter string
DryRun bool
Fingerprint string
}
// PreviewRow is one row of the import preview (UI-SPEC C13): the file
// line, the change (new, changed, unchanged or refused), the address, a
// changed field with its before and after value, and the note; Warning
// marks a row applied with a warning.
type PreviewRow struct {
Line int `json:"line"`
Change string `json:"change"`
Address string `json:"address"`
Field string `json:"field"`
Before string `json:"before"`
After string `json:"after"`
Note string `json:"note"`
Warning bool `json:"warning,omitempty"`
}
// ImportSummary counts the rows of an import by change.
type ImportSummary struct {
New int `json:"new"`
Changed int `json:"changed"`
Unchanged int `json:"unchanged"`
Refused int `json:"refused"`
Warning int `json:"warning"`
}
// ImportResult is an import's answer: the summary, the preview rows
// (refused first), the number of file rows, the number of changes an
// apply writes, the fingerprint of the data the preview read, and after
// an apply the batch it wrote.
type ImportResult struct {
Dataset string `json:"dataset"`
Summary ImportSummary `json:"summary"`
Rows []PreviewRow `json:"rows"`
Total int `json:"total"`
Count int `json:"count"`
Applied bool `json:"applied"`
BatchID string `json:"batch_id,omitempty"`
Fingerprint string `json:"-"`
}
// planned is one file row mapped to a change.
type planned struct {
row CSVRow
address string
status string // new, changed, unchanged, refused
change int // index in the batch, -1 for none
diffs [][3]string
notes []string
state any
}
// Import maps a file's rows to changes and runs them as one batch with
// via import (D-12, D-13, P-27): a preview is a dry run answering every
// row's change, the notes (loops refused, chains, canonicals that
// redirect, rows that create a URL rule) and the fingerprint of the data
// it read; an apply runs the preview again, refuses with
// *ImportRefusedError while a row is refused, with transfer.stale when
// the fingerprint differs from req.Fingerprint, and applies every change
// or none.
func (s *Service) Import(ctx context.Context, req ImportRequest) (ImportResult, error) {
header, rows, err := ReadCSV(req.CSV, req.Delimiter)
if err != nil {
return ImportResult{}, &TransferError{Code: "unreadable", Key: "golem15.seo::lang.transfer.unreadable"}
}
columns, ok := DatasetColumns[req.Dataset]
if !ok {
return ImportResult{}, &TransferError{Code: "dataset_unknown", Key: "golem15.seo::lang.transfer.dataset_unknown"}
}
if !contains(header, columns[0]) {
return ImportResult{}, &TransferError{Code: "missing_column", Key: "golem15.seo::lang.transfer.missing_column", Params: map[string]string{"column": columns[0]}}
}
preview, err := s.importPass(ctx, req.Dataset, header, rows, true, req.Fingerprint)
if err != nil || req.DryRun {
return preview, err
}
if preview.Summary.Refused > 0 {
return preview, &ImportRefusedError{Result: preview}
}
if preview.Count == 0 {
preview.Applied = true
return preview, nil
}
return s.importPass(ctx, req.Dataset, header, rows, false, preview.Fingerprint)
}
// contains reports whether list holds v.
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// importPass maps and runs the rows once.
func (s *Service) importPass(ctx context.Context, dataset string, header []string, rows []CSVRow, dryRun bool, expect string) (ImportResult, error) {
res := ImportResult{Dataset: dataset, Total: len(rows), Rows: []PreviewRow{}}
var plan []planned
batch, err := s.runBatch(ctx, BatchOptions{DryRun: dryRun, Via: models.ViaImport}, func(ctx context.Context, tx *gorm.DB) ([]BatchChange, error) {
var changes []BatchChange
var err error
plan, changes, err = s.planImport(ctx, tx, dataset, header, rows)
if err != nil {
return nil, err
}
res.Fingerprint = fingerprint(dataset, plan)
if expect != "" && res.Fingerprint != expect {
return nil, ErrStale
}
return changes, nil
})
var refused *BatchRefusedError
switch {
case errors.Is(err, ErrStale):
return res, &TransferError{Code: "stale", Key: "golem15.seo::lang.transfer.stale"}
case errors.As(err, &refused):
batch = refused.Result
case err != nil:
return res, err
}
s.previewRows(ctx, &res, plan, batch)
if refused != nil && !dryRun {
return res, &ImportRefusedError{Result: res}
}
if !dryRun {
res.Applied, res.BatchID = true, batch.BatchID
}
return res, nil
}
// fingerprint hashes the data an import read: each row's address and the
// state of the object it changes, so an apply sees whether anything
// changed since its preview (T-32-25).
func fingerprint(dataset string, plan []planned) string {
h := sha256.New()
h.Write([]byte(dataset))
for _, p := range plan {
raw, _ := json.Marshal([]any{p.row.Line, p.address, p.state})
h.Write(raw)
}
return hex.EncodeToString(h.Sum(nil))
}
// cell returns a row's cell and whether the file has the column.
func cell(row CSVRow, column string) (string, bool) {
v, ok := row.Fields[column]
return v, ok
}
// planImport maps rows to batch changes against the data in tx.
func (s *Service) planImport(ctx context.Context, tx *gorm.DB, dataset string, header []string, rows []CSVRow) ([]planned, []BatchChange, error) {
var plan []planned
var changes []BatchChange
add := func(p planned, ch *BatchChange) {
p.change = -1
if ch != nil && p.status != "refused" {
p.change = len(changes)
changes = append(changes, *ch)
}
plan = append(plan, p)
}
for _, row := range rows {
switch dataset {
case DatasetRedirects:
p, ch, err := s.planRedirect(ctx, tx, row)
if err != nil {
return nil, nil, err
}
add(p, ch)
case DatasetRules:
p, ch, err := s.planRule(tx, row)
if err != nil {
return nil, nil, err
}
add(p, ch)
case DatasetRecords:
p, ch, err := s.planRecord(ctx, tx, row)
if err != nil {
return nil, nil, err
}
add(p, ch)
}
}
return plan, changes, nil
}
// planRedirect maps a redirects row: the redirect with the same source and
// match is updated, else one is created.
func (s *Service) planRedirect(ctx context.Context, tx *gorm.DB, row CSVRow) (planned, *BatchChange, error) {
source, _ := cell(row, "source")
match, _ := cell(row, "match")
if match == "" {
match = models.RedirectExact
}
p := planned{row: row, address: source}
f := RedirectFields{Source: &source, Match: &match}
for _, column := range []string{"target", "query", "note"} {
if v, ok := cell(row, column); ok {
v := v
switch column {
case "target":
f.Target = &v
case "query":
if v != "" {
f.Query = &v
}
case "note":
f.Note = &v
}
}
}
if v, ok := cell(row, "status"); ok && v != "" {
n, err := strconv.Atoi(v)
if err != nil {
p.status = "refused"
p.notes = append(p.notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_invalid", map[string]string{"field": "status", "message": v}))
return p, nil, nil
}
f.Status = &n
}
for _, column := range []string{"ignore_case", "trailing_slash"} {
v, ok := cell(row, column)
if !ok || v == "" {
continue
}
b, valid := parseBool(v)
if !valid {
p.status = "refused"
p.notes = append(p.notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_invalid", map[string]string{"field": column, "message": v}))
return p, nil, nil
}
if column == "ignore_case" {
f.IgnoreCase = &b
} else {
f.TrailingSlash = &b
}
}
var rows []models.Redirect
if err := tx.Where("source = ? AND match = ?", strings.TrimSpace(source), strings.TrimSpace(match)).Limit(1).Find(&rows).Error; err != nil {
return p, nil, err
}
if len(rows) == 0 {
p.status = "new"
after := models.NewRedirect()
f.Apply(&after)
p.diffs = append(p.diffs, [3]string{"target", "", redirectSummary(after)})
return p, &BatchChange{Op: OpCreateRedirect, Redirect: &f}, nil
}
cur := rows[0]
p.state = RedirectValues(cur)
next := cur
f.Apply(&next)
next.Normalize()
before, after := RedirectValues(cur), RedirectValues(next)
for _, column := range DatasetColumns[DatasetRedirects] {
if !sameValue(before[column], after[column]) {
p.diffs = append(p.diffs, [3]string{column, text(jsonNorm(before[column])), text(jsonNorm(after[column]))})
}
}
if len(p.diffs) == 0 {
p.status = "unchanged"
return p, nil, nil
}
p.status = "changed"
return p, &BatchChange{Op: OpUpdateRedirect, ID: cur.ID, Redirect: &f}, nil
}
// redirectSummary is a new redirect in the preview: "→ target (301)".
func redirectSummary(r models.Redirect) string {
if r.Status == models.Status410 {
return "410"
}
return fmt.Sprintf("→ %s (%d)", r.Target, int(r.Status))
}
// planRule maps a rules row: the rule with the same address and match is
// updated, else one is created.
func (s *Service) planRule(tx *gorm.DB, row CSVRow) (planned, *BatchChange, error) {
url, _ := cell(row, "url")
match, _ := cell(row, "match")
match = models.NormalizeMatch(match)
p := planned{row: row, address: url}
f := RuleFields{URL: &url, Match: &match}
values := map[string]*string{}
for _, column := range DatasetColumns[DatasetRules][2:] {
if v, ok := cell(row, column); ok {
v := strings.TrimSpace(v)
if column == "robots" && v != "" {
if normal, err := NormalizeRobots(v); err == nil {
v = normal
}
}
values[column] = &v
}
}
f.Title, f.Description, f.Canonical, f.Robots = values["title"], values["description"], values["canonical"], values["robots"]
f.OGTitle, f.OGDescription, f.OGImage, f.Note = values["og_title"], values["og_description"], values["og_image"], values["note"]
var rows []models.Rule
if err := tx.Where("url = ? AND match = ?", strings.TrimSpace(url), match).Limit(1).Find(&rows).Error; err != nil {
return p, nil, err
}
if len(rows) == 0 {
p.status = "new"
for _, column := range DatasetColumns[DatasetRules][2:] {
if v := values[column]; v != nil && *v != "" {
p.diffs = append(p.diffs, [3]string{column, "", *v})
}
}
return p, &BatchChange{Op: OpCreateRule, Rule: &f}, nil
}
cur := rows[0]
p.state = RuleValues(cur)
for column, v := range values {
if old := ruleColumn(cur, column); old != *v {
p.diffs = append(p.diffs, [3]string{column, old, *v})
}
}
sortDiffs(p.diffs, DatasetColumns[DatasetRules])
if len(p.diffs) == 0 {
p.status = "unchanged"
return p, nil, nil
}
p.status = "changed"
return p, &BatchChange{Op: OpUpdateRule, ID: cur.ID, Rule: &f}, nil
}
// ruleColumn reads one column of a rule.
func ruleColumn(r models.Rule, column string) string {
switch column {
case "url":
return r.URL
case "match":
return r.Match
case "note":
return r.Note
}
return r.Value(column)
}
// sortDiffs orders diffs by the dataset's column order.
func sortDiffs(diffs [][3]string, columns []string) {
pos := map[string]int{}
for i, c := range columns {
pos[c] = i
}
sort.SliceStable(diffs, func(i, j int) bool { return pos[diffs[i][0]] < pos[diffs[j][0]] })
}
// planRecord maps a records row by URL (D-11): the record behind the URL
// in the row's locale, else an exact URL rule (transfer.row_rule).
func (s *Service) planRecord(ctx context.Context, tx *gorm.DB, row CSVRow) (planned, *BatchChange, error) {
url, _ := cell(row, "url")
locale, _ := cell(row, "locale")
p := planned{row: row, address: url}
owner, _, err := s.FindOwner(ctx, tx, url, false)
if errors.Is(err, ErrBadURL) {
p.status = "refused"
p.notes = append(p.notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_invalid", map[string]string{"field": "url", "message": err.Error()}))
return p, nil, nil
}
if err != nil {
return p, nil, err
}
p.address = owner.URL
values := map[string]string{}
for _, column := range DatasetColumns[DatasetRecords][2:] {
if v, ok := cell(row, column); ok {
v = strings.TrimSpace(v)
if column == "robots" && v != "" {
if normal, err := NormalizeRobots(v); err == nil {
v = normal
}
}
values[column] = v
}
}
current := map[string]string{}
exists := false
if owner.WrittenTo == WrittenToRecord {
if locale == "" {
locale = owner.Locale
}
typ, _ := s.Registry().ContentType(owner.Type)
e, found, err := LoadEntry(tx, typ.Content.Model, owner.ID, locale)
if err != nil {
return p, nil, err
}
exists = true
for _, column := range EntryColumns {
if found {
current[column] = EntryValue(e, column)
} else {
current[column] = ""
}
}
p.state = []any{owner.Type, owner.ID, locale, current}
} else {
p.notes = append(p.notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_rule", nil))
if owner.RuleID != 0 {
r, err := LoadRule(tx, owner.RuleID)
if err != nil {
return p, nil, err
}
exists = true
for _, column := range EntryColumns {
current[column] = r.Value(column)
}
p.state = RuleValues(r)
}
if values["keyphrase"] == "" {
delete(values, "keyphrase")
}
}
changed := map[string]string{}
for column, v := range values {
if current[column] != v {
changed[column] = v
p.diffs = append(p.diffs, [3]string{column, current[column], v})
}
}
sortDiffs(p.diffs, DatasetColumns[DatasetRecords])
switch {
case len(changed) == 0:
p.status = "unchanged"
return p, nil, nil
case exists:
p.status = "changed"
default:
p.status = "new"
}
return p, &BatchChange{Op: OpSetSEO, URL: url, Locale: locale, Values: changed}, nil
}
// previewRows fills the summary, the preview rows and the count from the
// plan and the batch it ran.
func (s *Service) previewRows(ctx context.Context, res *ImportResult, plan []planned, batch BatchResult) {
var refused, others []PreviewRow
for _, p := range plan {
status, notes := p.status, append([]string{}, p.notes...)
warning := false
if p.change >= 0 && p.change < len(batch.Changes) {
item := batch.Changes[p.change]
if item.Error != "" {
status = "refused"
switch {
case item.Error == ConflictLoop:
notes = append(notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_loop", map[string]string{"path": strings.Join(item.Path, " → ")}))
case len(item.Errors) > 0:
fields := make([]string, 0, len(item.Errors))
for f := range item.Errors {
fields = append(fields, f)
}
sort.Strings(fields)
for _, f := range fields {
notes = append(notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_invalid", map[string]string{"field": f, "message": strings.Join(item.Errors[f], " ")}))
}
default:
notes = append(notes, item.Message)
}
}
for _, c := range item.Conflicts {
switch c.Kind {
case ConflictLoop:
case ConflictChain:
warning = true
notes = append(notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_chain", map[string]string{"path": strings.Join(c.Path, " → ")}))
default:
warning = true
notes = append(notes, c.Message)
}
}
}
switch status {
case "new":
res.Summary.New++
case "changed":
res.Summary.Changed++
case "unchanged":
res.Summary.Unchanged++
case "refused":
res.Summary.Refused++
}
if warning && status != "refused" {
res.Summary.Warning++
}
if status == "new" || status == "changed" {
res.Count++
}
note := strings.Join(notes, " ")
base := PreviewRow{Line: p.row.Line, Change: status, Address: p.address, Note: note, Warning: warning && status != "refused"}
var rows []PreviewRow
if status == "changed" || status == "new" {
for _, d := range p.diffs {
r := base
r.Field, r.Before, r.After = d[0], d[1], d[2]
rows = append(rows, r)
}
}
if len(rows) == 0 {
rows = []PreviewRow{base}
}
if status == "refused" {
refused = append(refused, rows...)
} else {
others = append(others, rows...)
}
}
res.Rows = append(refused, others...)
}
// The transfer token (T-32-25): the preview's dataset, delimiter, file and
// fingerprint, gzip-compressed and signed with HMAC-SHA256 under the app
// key, so the apply needs no server-side state and a forged or edited
// token is refused.
type transferToken struct {
Dataset string `json:"d"`
Delimiter string `json:"l"`
CSV string `json:"c"`
Fingerprint string `json:"f"`
}
var (
fallbackKeyOnce sync.Once
fallbackKey []byte
)
// tokenKey is the HMAC key of transfer tokens: derived from the app key,
// or a random key per process when the app has none (tokens then do not
// survive a restart).
func (s *Service) tokenKey() []byte {
if cfg := s.Config(); cfg != nil {
if primary, _, err := lagoon.LoadAppKey(cfg); err == nil && len(primary) > 0 {
mac := hmac.New(sha256.New, primary)
mac.Write([]byte("golem15.seo.transfer"))
return mac.Sum(nil)
}
}
fallbackKeyOnce.Do(func() {
fallbackKey = make([]byte, 32)
if _, err := rand.Read(fallbackKey); err != nil {
panic(err)
}
})
return fallbackKey
}
// TransferToken signs a preview for its apply.
func (s *Service) TransferToken(dataset, delimiter, csv, fp string) (string, error) {
raw, err := json.Marshal(transferToken{Dataset: dataset, Delimiter: delimiter, CSV: csv, Fingerprint: fp})
if err != nil {
return "", err
}
var b bytes.Buffer
zw := gzip.NewWriter(&b)
if _, err := zw.Write(raw); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.tokenKey())
mac.Write(b.Bytes())
return base64.RawURLEncoding.EncodeToString(b.Bytes()) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// ErrBadToken refuses a transfer token that is malformed or not signed by
// this application.
var ErrBadToken = errors.New("golem15.seo: the import token is not valid")
// ReadTransferToken verifies a token and returns the import it applies:
// the preview's file with the fingerprint the apply must find.
func (s *Service) ReadTransferToken(token string) (ImportRequest, error) {
body, sig, ok := strings.Cut(strings.TrimSpace(token), ".")
if !ok {
return ImportRequest{}, ErrBadToken
}
payload, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return ImportRequest{}, ErrBadToken
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil {
return ImportRequest{}, ErrBadToken
}
mac := hmac.New(sha256.New, s.tokenKey())
mac.Write(payload)
if !hmac.Equal(got, mac.Sum(nil)) {
return ImportRequest{}, ErrBadToken
}
zr, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return ImportRequest{}, ErrBadToken
}
raw, err := io.ReadAll(io.LimitReader(zr, 4<<20))
if err != nil {
return ImportRequest{}, ErrBadToken
}
var t transferToken
if err := json.Unmarshal(raw, &t); err != nil {
return ImportRequest{}, ErrBadToken
}
return ImportRequest{Dataset: t.Dataset, Delimiter: t.Delimiter, CSV: t.CSV, Fingerprint: t.Fingerprint}, nil
}
// ChoiceCtx translates a plural phrase for count in the request locale,
// with :count set.
func (s *Service) ChoiceCtx(ctx context.Context, key string, count int, params map[string]string) string {
tr := s.translator()
if tr == nil {
return key
}
p := map[string]string{"count": strconv.Itoa(count)}
for k, v := range params {
p[k] = v
}
return tr.Choice(ctx, key, count, p)
}

540
classes/undo.go Normal file
View File

@@ -0,0 +1,540 @@
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)
}
}