AddressChanged wrote its 301 and re-pointed rules without Problems or the save-time simulation, and removed only an exact rule whose source equals the new address literally. It now removes every exact rule that matches the new address with its options, simulates the 301 and the re-pointing with the stored rules and writes nothing when that loops, stores an absolute old address on the site as its path, and skips an address that is not a path on the site.
290 lines
9.2 KiB
Go
290 lines
9.2 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
|
|
}
|
|
|
|
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
|
|
}
|