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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user