fix: address changes run the loop simulation and validate their addresses (WR-09)

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.
This commit is contained in:
2026-10-27 15:27:21 +01:00
parent 58b2504fe3
commit cb614cb1fb
3 changed files with 184 additions and 67 deletions

View File

@@ -36,99 +36,162 @@ type AddressResult struct {
// 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);
// 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.
// 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: strings.TrimSpace(change.Old), New: strings.TrimSpace(change.New)}
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()
var shadowing []models.Redirect
if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.New).Find(&shadowing).Error; err != nil {
if err := s.applyAddressRedirects(ctx, tx, &res, now); 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 {
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 {