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:
475
classes/batch.go
Normal file
475
classes/batch.go
Normal 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
145
classes/csv.go
Normal 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
|
||||
}
|
||||
@@ -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
802
classes/transfer.go
Normal 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
540
classes/undo.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user