The save-time simulation read the rules at READ COMMITTED without a lock, so A -> B and B -> A written concurrently both passed. CheckRedirect, AddressChanged and every batch now take a transaction-level advisory lock before they read the rules.
293 lines
9.3 KiB
Go
293 lines
9.3 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 that match the new address (with their case and
|
|
// trailing-slash options) 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 (as the descriptor wrote it).
|
|
//
|
|
// Steps 2 to 4 are simulated first with the stored rules (P-19): when the
|
|
// result would loop (a pattern or regex rule that sends the new address
|
|
// back), or when either address is not a path on this site, no redirect is
|
|
// written, a warning is logged and only the entry's URL moves; the result's
|
|
// Redirect is then nil.
|
|
//
|
|
// 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: s.sitePath(change.Old), New: s.sitePath(change.New)}
|
|
if res.Old == "" || res.New == "" || res.Old == res.New {
|
|
return res, nil
|
|
}
|
|
ctx = WithVia(ctx, models.ViaAuto)
|
|
now := time.Now().UTC()
|
|
|
|
if err := s.applyAddressRedirects(ctx, tx, &res, now); err != nil {
|
|
return res, err
|
|
}
|
|
|
|
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": strings.TrimSpace(change.New), "updated_at": now}).Error; err != nil {
|
|
return res, err
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// sitePath trims an address and turns an absolute URL on the site URL into
|
|
// its path, so the redirect source is the form the resolver matches.
|
|
func (s *Service) sitePath(u string) string {
|
|
u = strings.TrimSpace(u)
|
|
if site := s.SiteURL(); site != "" && strings.HasPrefix(u, site+"/") {
|
|
return strings.TrimPrefix(u, site)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// applyAddressRedirects runs steps 1 to 4 of AddressChanged.
|
|
func (s *Service) applyAddressRedirects(ctx context.Context, tx *gorm.DB, res *AddressResult, now time.Time) error {
|
|
candidate := models.NewRedirect()
|
|
candidate.Source, candidate.Target, candidate.Origin = res.Old, res.New, models.OriginSlug
|
|
newPath, newQuery, _ := strings.Cut(res.New, "?")
|
|
if problems := candidate.Problems(nil); len(problems) > 0 || !strings.HasPrefix(res.New, "/") {
|
|
log.Warn("golem15.seo: address change is not a path on the site, no redirect created", "old", res.Old, "new", res.New)
|
|
return nil
|
|
}
|
|
|
|
if err := lockRedirectWrites(tx.WithContext(ctx)); err != nil {
|
|
return err
|
|
}
|
|
rules, err := LoadRedirects(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kept := rules[:0:0]
|
|
for _, r := range rules {
|
|
if r.Match == models.RedirectExact && exactMatches(r, newPath, newQuery) {
|
|
if err := s.deleteRedirect(ctx, tx, r); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
kept = append(kept, r)
|
|
}
|
|
|
|
// Simulate steps 2 to 4 on a copy of the remaining rules.
|
|
redirect := candidate
|
|
for _, r := range kept {
|
|
if r.Match == models.RedirectExact && r.Source == res.Old {
|
|
redirect = r
|
|
redirect.Target, redirect.Status = res.New, models.Status301
|
|
break
|
|
}
|
|
}
|
|
targets := map[string]bool{res.Old: true}
|
|
if site := s.SiteURL(); site != "" {
|
|
targets[site+res.Old] = true
|
|
}
|
|
simulated := make([]models.Redirect, 0, len(kept))
|
|
for _, r := range kept {
|
|
if r.ID != redirect.ID && targets[r.Target] {
|
|
r.Target = res.New
|
|
if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) {
|
|
continue
|
|
}
|
|
}
|
|
simulated = append(simulated, r)
|
|
}
|
|
check, err := checkRedirectRules(s.SiteURL(), simulated, redirect)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if check.Loop {
|
|
log.Warn("golem15.seo: address change would create a redirect loop, no redirect created", "old", res.Old, "new", res.New, "path", check.PathText())
|
|
return nil
|
|
}
|
|
|
|
if redirect.ID != 0 {
|
|
var stored models.Redirect
|
|
for _, r := range kept {
|
|
if r.ID == redirect.ID {
|
|
stored = r
|
|
}
|
|
}
|
|
before := RedirectValues(stored)
|
|
redirect.UpdatedAt = &now
|
|
if err := tx.Save(&redirect).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.recordRedirect(ctx, tx, redirect, before, RedirectValues(redirect)); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
redirect.CreatedAt, redirect.UpdatedAt = &now, &now
|
|
if err := tx.Create(&redirect).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.recordRedirect(ctx, tx, redirect, nil, RedirectValues(redirect)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
res.Redirect = &redirect
|
|
|
|
for _, r := range kept {
|
|
if r.ID == redirect.ID || !targets[r.Target] {
|
|
continue
|
|
}
|
|
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 err
|
|
}
|
|
continue
|
|
}
|
|
if err := tx.Save(&r).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.recordRedirect(ctx, tx, r, before, RedirectValues(r)); err != nil {
|
|
return err
|
|
}
|
|
res.Repointed++
|
|
}
|
|
return 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
|
|
}
|