feat: redirect engine with loop refusal and chain notices

- rule set: exact sources by key, patterns by literal prefix, regex by id
  (stdlib RE2, compiled once per source); ignore case, trailing slash,
  query ignore/pass, a source with ? matches its query exactly
- targets: captures escaped, a relative target never leaves the site, an
  absolute one keeps its host
- SaveRedirect validates, simulates the rule in place (exact, pattern and
  regex probes, 10 hops) and refuses a loop; chains answer the notice
- resolve follows the chain to its final target, 410 answers gone, a
  runtime cycle answers not_found and is logged once
This commit is contained in:
2026-10-27 11:26:42 +01:00
parent e31ac7c1c6
commit 34a65b846e
8 changed files with 1221 additions and 21 deletions

View File

@@ -7,12 +7,12 @@ import (
// createRedirects creates golem15_seo_redirects: 301, 302 and 410 rules
// with exact, pattern and regex sources (D-16, D-17), one per source and
// match.
// match, indexed on the lowercased source for the resolver's lookup.
var createRedirects = []*gormigrate.Migration{
{
ID: "202610270006_create_golem15_seo_redirects",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE golem15_seo_redirects (
if err := tx.Exec(`CREATE TABLE golem15_seo_redirects (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL,
match TEXT NOT NULL DEFAULT 'exact',
@@ -26,7 +26,11 @@ var createRedirects = []*gormigrate.Migration{
created_at TIMESTAMP(0) NULL,
updated_at TIMESTAMP(0) NULL,
CONSTRAINT golem15_seo_redirects_source_match UNIQUE (source, match)
)`).Error
)`).Error; err != nil {
return err
}
// The resolver reads exact rules by their lowercased source.
return tx.Exec(`CREATE INDEX golem15_seo_redirects_lower_source ON golem15_seo_redirects (lower(source))`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_redirects`).Error