Files
sm-seo-plugin/classes/addresses.go
Jakub Zych e31ac7c1c6 feat: address-change 301 (tracer)
- golem15_seo_redirects table and Redirect model
- AddressChanged: old -> new 301 in the save transaction, earlier
  redirects pointed at the new address, self-loops dropped
- the SEO tab save compares descriptor URLs with the entry URLs and
  answers the redirects.auto_created notice
- resolve answers redirect with the absolute target for an exact source
2026-10-27 11:20:59 +01:00

227 lines
7.4 KiB
Go

package classes
import (
"context"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// AddressChange is one record's public address change in one locale: the
// URL its SEO entry last saw (Old) and the URL its content descriptor
// answers now (New).
type AddressChange struct {
TypeKey string
ModelType string
RecordID string
Locale string
Old string
New string
}
// AddressResult is what AddressChanged did: the redirect from the old to
// the new address, and how many earlier redirects now point straight to
// the new address.
type AddressResult struct {
Locale string
Old string
New string
Redirect *models.Redirect
Repointed int
}
// AddressChanged applies a record's address change inside tx, the
// caller's transaction (D-18, P-20):
// 1. exact redirects whose source is the new address are deleted, so the
// record is never shadowed (this also undoes an earlier change back);
// 2. old → new is created as a 301 (origin slug), or the existing exact
// rule of the old address is pointed at the new one;
// 3. redirects whose target is the old address are pointed at the new
// one, so no chain forms (A→B, then B→C makes A→C);
// 4. redirects that now point to themselves are deleted;
// 5. the entry's URL becomes the new address.
//
// Every redirect write is recorded in history with via auto. Nothing
// happens when either address is empty or they are equal.
func (s *Service) AddressChanged(ctx context.Context, tx *gorm.DB, change AddressChange) (AddressResult, error) {
res := AddressResult{Locale: change.Locale, Old: strings.TrimSpace(change.Old), New: strings.TrimSpace(change.New)}
if res.Old == "" || res.New == "" || res.Old == res.New {
return res, nil
}
ctx = WithVia(ctx, models.ViaAuto)
now := time.Now().UTC()
var shadowing []models.Redirect
if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.New).Find(&shadowing).Error; err != nil {
return res, err
}
for _, r := range shadowing {
if err := s.deleteRedirect(ctx, tx, r); err != nil {
return res, err
}
}
var existing []models.Redirect
if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.Old).Limit(1).Find(&existing).Error; err != nil {
return res, err
}
var redirect models.Redirect
if len(existing) > 0 {
redirect = existing[0]
before := RedirectValues(redirect)
redirect.Target, redirect.Status, redirect.UpdatedAt = res.New, models.Status301, &now
if err := tx.Save(&redirect).Error; err != nil {
return res, err
}
if err := s.recordRedirect(ctx, tx, redirect, before, RedirectValues(redirect)); err != nil {
return res, err
}
} else {
redirect = models.NewRedirect()
redirect.Source, redirect.Target, redirect.Origin = res.Old, res.New, models.OriginSlug
redirect.CreatedAt, redirect.UpdatedAt = &now, &now
if err := tx.Create(&redirect).Error; err != nil {
return res, err
}
if err := s.recordRedirect(ctx, tx, redirect, nil, RedirectValues(redirect)); err != nil {
return res, err
}
}
res.Redirect = &redirect
var pointing []models.Redirect
targets := []string{res.Old}
if site := s.SiteURL(); site != "" {
targets = append(targets, site+res.Old)
}
if err := tx.Where("target IN ? AND id <> ?", targets, redirect.ID).Find(&pointing).Error; err != nil {
return res, err
}
for _, r := range pointing {
before := RedirectValues(r)
r.Target, r.UpdatedAt = res.New, &now
if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) {
if err := s.deleteRedirect(ctx, tx, r); err != nil {
return res, err
}
continue
}
if err := tx.Save(&r).Error; err != nil {
return res, err
}
if err := s.recordRedirect(ctx, tx, r, before, RedirectValues(r)); err != nil {
return res, err
}
res.Repointed++
}
if change.ModelType != "" && change.RecordID != "" {
if err := tx.Model(&models.Entry{}).
Where("model_type = ? AND record_id = ? AND locale = ?", change.ModelType, change.RecordID, change.Locale).
Updates(map[string]any{"url": res.New, "updated_at": now}).Error; err != nil {
return res, err
}
}
return res, nil
}
// deleteRedirect deletes r inside tx and records it in history.
func (s *Service) deleteRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect) error {
if err := tx.Delete(&models.Redirect{}, r.ID).Error; err != nil {
return err
}
return s.recordRedirect(ctx, tx, r, RedirectValues(r), nil)
}
// recordRedirect writes the history row of one redirect write.
func (s *Service) recordRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect, before, after map[string]any) error {
return s.Record(ctx, tx, Change{
Kind: models.SubjectRedirect,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: s.redirectWhat(r),
Before: before,
After: after,
})
}
// AddressChanges compares the addresses a record's content descriptor
// answers now with the URLs its SEO entries last saw, inside tx (D-18),
// and returns the changes that need a redirect: the changed locales of a
// published record, each to be applied with AddressChanged. A changed
// locale of an unpublished record only takes its new URL, without a
// redirect, and a locale without an entry gets one with its URL, so the
// next change can be detected.
func (s *Service) AddressChanges(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string) ([]AddressChange, error) {
reg := s.Registry()
if reg == nil || typ.Content == nil {
return nil, nil
}
rec, err := reg.Describe(ctx, typ.Key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return nil, err
}
model := typ.Content.Model
var out []AddressChange
for _, l := range rec.Locales {
url := strings.TrimSpace(l.URL)
if url == "" {
continue
}
entry, found, err := LoadEntry(tx, model, id, l.Locale)
if err != nil {
return nil, err
}
switch {
case !found:
row := models.Entry{ModelType: model, RecordID: id, Locale: l.Locale, TypeKey: typ.Key, URL: url}
if err := tx.Create(&row).Error; err != nil {
return nil, err
}
case entry.URL == url:
case entry.URL == "" || !rec.Published:
if err := tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{"url": url, "type_key": typ.Key}).Error; err != nil {
return nil, err
}
default:
out = append(out, AddressChange{
TypeKey: typ.Key, ModelType: model, RecordID: id, Locale: l.Locale,
Old: entry.URL, New: url,
})
}
}
return out, nil
}
// AddressNotices are the save notices of address changes
// (redirects.auto_created, one per changed locale), on the owner field
// named after a placeholder of the type's pattern that the save changed
// (for example slug), else on fallback.
func AddressNotices(typ pact.MenuItemType, changes []pact.RecordChange, results []AddressResult, fallback string) map[string][]pact.FormFieldNotice {
if len(results) == 0 {
return nil
}
field := fallback
for _, c := range changes {
for _, p := range typ.Placeholders {
if strings.TrimPrefix(p, ":") == c.Attribute {
field = c.Attribute
}
}
if field != fallback {
break
}
}
out := map[string][]pact.FormFieldNotice{}
for _, r := range results {
out[field] = append(out[field], pact.FormFieldNotice{
Message: "golem15.seo::lang.redirects.auto_created",
Params: map[string]string{"old": r.Old, "new": r.New, "count": strconv.Itoa(r.Repointed)},
})
}
return out
}