- every write that records history (admin, MCP, batch, import, undo, automatic) queues the framework's site-cache purge (kind seo, slug the address or * for settings and robots.txt) after its commit, once per transaction; a rolled-back write never purges - the record-deleted cleanup and the address tracking of saves outside the admin run in lagoon transactions, so their history purges as well
356 lines
9.6 KiB
Go
356 lines
9.6 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 and robots.txt,
|
|
// else the changed object's address.
|
|
func purgeSlug(c Change) string {
|
|
if c.Kind == models.SubjectSettings || c.Kind == models.SubjectRobots {
|
|
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)
|
|
}
|
|
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
|
|
}
|