261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type viaKey struct{}
|
|
|
|
// 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 and via 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 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{
|
|
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
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|