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 }