- golem15_seo_autolinks (migration 202610280007) and the settings column autolink_max (Links tab, 0 to 20, default 3) - classes/autolinks.go: checked save and delete with history, ForPage - ok resolve answers carry autolinks for the page's locale without disabled phrases and links to the page itself, and autolink_max
362 lines
9.8 KiB
Go
362 lines
9.8 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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 {
|
|
return context.WithValue(ctx, viaKey{}, via)
|
|
}
|
|
|
|
// ViaOf is the writer history records for ctx: a WithVia mark, else mcp
|
|
// when an MCP dispatch for the backend guard carries the request
|
|
// (see mcpPrincipal), else admin.
|
|
func ViaOf(ctx context.Context) string {
|
|
if v, ok := ctx.Value(viaKey{}).(string); ok && v != "" {
|
|
return v
|
|
}
|
|
if _, ok := mcpPrincipal(ctx); ok {
|
|
return models.ViaMCP
|
|
}
|
|
return models.ViaAdmin
|
|
}
|
|
|
|
// mcpPrincipal returns the principal of an MCP dispatch for the backend
|
|
// guard, which the MCP module attaches to the requests it builds.
|
|
func mcpPrincipal(ctx context.Context) (*bouncer.Principal, bool) {
|
|
d, ok := bouncer.Dispatched(ctx, "backend")
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return d.Principal, true
|
|
}
|
|
|
|
// Change is one changed object for history. Before and After hold the
|
|
// object's values; Record keeps only the fields whose value changed. A
|
|
// created object has a nil Before, a deleted one a nil After.
|
|
type Change struct {
|
|
Kind string
|
|
SubjectID string
|
|
What string
|
|
Before map[string]any
|
|
After map[string]any
|
|
}
|
|
|
|
// diffValues keeps the keys whose value differs between before and after;
|
|
// nil before or after keeps every key of the other side.
|
|
func diffValues(before, after map[string]any) (b, a map[string]any) {
|
|
if before == nil || after == nil {
|
|
return before, after
|
|
}
|
|
b, a = map[string]any{}, map[string]any{}
|
|
keys := map[string]bool{}
|
|
for k := range before {
|
|
keys[k] = true
|
|
}
|
|
for k := range after {
|
|
keys[k] = true
|
|
}
|
|
for k := range keys {
|
|
bv, av := before[k], after[k]
|
|
if !reflect.DeepEqual(plainValue(bv), plainValue(av)) {
|
|
b[k], a[k] = plainValue(bv), plainValue(av)
|
|
}
|
|
}
|
|
return b, a
|
|
}
|
|
|
|
// plainValue turns a model value into a JSON-friendly one: pointers are
|
|
// dereferenced, times formatted, and SQL valuers (jsonable columns) read
|
|
// through their stored JSON.
|
|
func plainValue(v any) any {
|
|
switch val := v.(type) {
|
|
case nil:
|
|
return nil
|
|
case string, bool, float64, int, int64, uint, uint64:
|
|
return val
|
|
case time.Time:
|
|
return val.UTC().Format(time.RFC3339)
|
|
case *time.Time:
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
return val.UTC().Format(time.RFC3339)
|
|
case json.Number:
|
|
return val.String()
|
|
case driver.Valuer:
|
|
raw, err := val.Value()
|
|
if err != nil || raw == nil {
|
|
return nil
|
|
}
|
|
if s, ok := raw.(string); ok {
|
|
var decoded any
|
|
if json.Unmarshal([]byte(s), &decoded) == nil {
|
|
return decoded
|
|
}
|
|
return s
|
|
}
|
|
return plainValue(raw)
|
|
}
|
|
rv := reflect.ValueOf(v)
|
|
if rv.Kind() == reflect.Pointer {
|
|
if rv.IsNil() {
|
|
return nil
|
|
}
|
|
return plainValue(rv.Elem().Interface())
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprint(v)
|
|
}
|
|
var decoded any
|
|
if json.Unmarshal(raw, &decoded) != nil {
|
|
return fmt.Sprint(v)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
// ChangesValues turns a save's record changes into before and after maps:
|
|
// the attribute name, with ".<locale>" for a translated value.
|
|
func ChangesValues(changes []pact.RecordChange, created bool) (before, after map[string]any) {
|
|
after = map[string]any{}
|
|
if !created {
|
|
before = map[string]any{}
|
|
}
|
|
for _, c := range changes {
|
|
key := c.Attribute
|
|
if c.Locale != "" {
|
|
key += "." + c.Locale
|
|
}
|
|
if before != nil {
|
|
before[key] = plainValue(c.Old)
|
|
}
|
|
after[key] = plainValue(c.New)
|
|
}
|
|
return before, after
|
|
}
|
|
|
|
// author returns the author of a write: the backend principal's id and a
|
|
// label (the administrator's name or login; "MCP: <label>" through MCP).
|
|
func (s *Service) author(ctx context.Context, db *gorm.DB, via string) (*uint, string) {
|
|
p, ok := mcpPrincipal(ctx)
|
|
if !ok {
|
|
p, _ = bouncer.User(ctx)
|
|
}
|
|
if p == nil || p.ID == 0 {
|
|
return nil, ""
|
|
}
|
|
id := p.ID
|
|
var row struct {
|
|
FirstName string
|
|
LastName string
|
|
Login string
|
|
}
|
|
label := fmt.Sprintf("#%d", id)
|
|
if err := db.Raw(`SELECT first_name, last_name, login FROM backend_users WHERE id = ?`, id).Scan(&row).Error; err == nil {
|
|
if name := strings.TrimSpace(row.FirstName + " " + row.LastName); name != "" {
|
|
label = name
|
|
} else if row.Login != "" {
|
|
label = row.Login
|
|
}
|
|
}
|
|
if via == models.ViaMCP {
|
|
label = "MCP: " + label
|
|
}
|
|
return &id, label
|
|
}
|
|
|
|
// Record appends one history row per change whose values differ, inside
|
|
// 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
|
|
for _, c := range changes {
|
|
before, after := diffValues(c.Before, c.After)
|
|
if c.Before != nil && c.After != nil && len(after) == 0 {
|
|
continue
|
|
}
|
|
if !authored {
|
|
authorID, authorLabel = s.author(ctx, db, via)
|
|
authored = true
|
|
}
|
|
row := models.Revision{
|
|
BatchID: batch,
|
|
AuthorID: authorID,
|
|
AuthorLabel: authorLabel,
|
|
Via: via,
|
|
SubjectKind: c.Kind,
|
|
SubjectID: c.SubjectID,
|
|
What: c.What,
|
|
Before: models.JSONMap(before),
|
|
After: models.JSONMap(after),
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
if err := db.Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
s.purgeAfterCommit(ctx, db, purgeSlug(c))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SiteCacheKind is the purge kind of SEO writes (P33-19).
|
|
const SiteCacheKind = "seo"
|
|
|
|
// purgeAfterCommit queues the rendered site's cache purge once the write
|
|
// db belongs to commits (P33-19, D-97): every SEO write that records
|
|
// history (admin, MCP, batch, import, undo, automatic) refreshes the
|
|
// layer's cached resolve answers and pages. A transaction purges once,
|
|
// however many rows it records; a rolled-back write never purges. Without
|
|
// a configured purger (site_cache.purge_url) it does nothing.
|
|
func (s *Service) purgeAfterCommit(ctx context.Context, db *gorm.DB, slug string) {
|
|
if s == nil || s.app == nil {
|
|
return
|
|
}
|
|
p := cooler.SiteCacheFromApp(s.app)
|
|
if p == nil {
|
|
return
|
|
}
|
|
var pool gorm.ConnPool
|
|
if db != nil && db.Statement != nil {
|
|
if _, inTx := db.Statement.ConnPool.(gorm.TxCommitter); inTx {
|
|
pool = db.Statement.ConnPool
|
|
}
|
|
}
|
|
if pool != nil {
|
|
purgeMu.Lock()
|
|
if purging == nil || len(purging) > maxPendingPurges {
|
|
// Rolled-back transactions leave their key behind; the map is
|
|
// reset rather than grow.
|
|
purging = map[gorm.ConnPool]bool{}
|
|
}
|
|
if purging[pool] {
|
|
purgeMu.Unlock()
|
|
return
|
|
}
|
|
purging[pool] = true
|
|
purgeMu.Unlock()
|
|
lagoon.AfterCommit(ctx, db, func(context.Context, *gorm.DB) {
|
|
purgeMu.Lock()
|
|
delete(purging, pool)
|
|
purgeMu.Unlock()
|
|
})
|
|
}
|
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, p, SiteCacheKind, slug)
|
|
}
|
|
|
|
// maxPendingPurges caps the transactions purgeAfterCommit remembers.
|
|
const maxPendingPurges = 1024
|
|
|
|
// purging holds the open transactions whose site-cache purge is queued, so
|
|
// each purges once whichever Service instance records in it.
|
|
var (
|
|
purgeMu sync.Mutex
|
|
purging map[gorm.ConnPool]bool
|
|
)
|
|
|
|
// purgeSlug is the purge's slug: "*" for the settings, robots.txt and an
|
|
// auto-link phrase (it may appear in any text), else the changed object's
|
|
// address.
|
|
func purgeSlug(c Change) string {
|
|
if c.Kind == models.SubjectSettings || c.Kind == models.SubjectRobots || c.Kind == models.SubjectAutolink {
|
|
return "*"
|
|
}
|
|
for _, values := range []map[string]any{c.After, c.Before} {
|
|
for _, key := range []string{"url", "source"} {
|
|
if v, ok := values[key].(string); ok && strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
return "*"
|
|
}
|
|
|
|
// translator returns the application translator, nil when none.
|
|
func (s *Service) translator() *phrasebook.Translator {
|
|
if s == nil || s.app == nil {
|
|
return nil
|
|
}
|
|
tr, _ := s.app.Lookup[*phrasebook.Translator]()
|
|
return tr
|
|
}
|
|
|
|
// Phrase renders a phrase key with params in the application's default
|
|
// admin locale (history descriptions are stored as text, so the list can
|
|
// search them).
|
|
func (s *Service) Phrase(key string, params map[string]string) string {
|
|
tr := s.translator()
|
|
if tr == nil {
|
|
return key
|
|
}
|
|
return tr.GetIn(tr.Locale(), key, params)
|
|
}
|
|
|
|
// EntrySubject is the subject id of a record's entry in a locale.
|
|
func EntrySubject(modelType, recordID, locale string) string {
|
|
return modelType + "|" + recordID + "|" + locale
|
|
}
|
|
|
|
// entryValues are the editable values of an entry for history.
|
|
func entryValues(e models.Entry) map[string]any {
|
|
out := map[string]any{}
|
|
for _, column := range EntryColumns {
|
|
out[column] = entryValue(e, column)
|
|
}
|
|
for _, column := range DataColumns {
|
|
if v := entryData(e, column); v != "" {
|
|
out[column] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RuleValues are a rule's values for history.
|
|
func RuleValues(r models.Rule) map[string]any {
|
|
out := map[string]any{"url": r.URL, "match": r.Match, "note": r.Note}
|
|
for _, p := range models.RuleProperties {
|
|
out[p.Column] = r.Value(p.Column)
|
|
}
|
|
return out
|
|
}
|