fix: an exact redirect with a query wins over the path-only rule (WR-08)
Match tried the path-only key before the path?query key, so a rule for /products?id=5 never fired while /products had one.
This commit is contained in:
@@ -22,7 +22,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
|
||||
- **URL rules** (`golem15_seo_rules`): SEO for an exact address or a pattern (`/blog?tag=*`), with or without a record behind it. A rule wins over the record's tab.
|
||||
- **Settings** (`golem15_seo_settings`, one row): site name, title and home templates with `%title%`, `%description%` and `%site%`, default description and social image, X account, the query-parameter list (content, filter and tracking parameters, optionally per path), never-indexed and search paths, address options and verification codes and files. One tab per content type (`golem15_seo_type_settings`) holds the inject switch, the title and description templates and the default robots (tag types default to `noindex,follow`).
|
||||
- **Resolver.** Canonical URLs are self-referencing and absolute from the configured site URL, keep content parameters and drop filter and tracking parameters; overrides may be relative or absolute. Robots resolve per directive and are also answered as the `X-Robots-Tag` value. Open Graph and Twitter values inherit from the effective title, description and image. Verification meta tags come with the home page.
|
||||
- **Redirects** (`golem15_seo_redirects`): 301, 302 and 410 rules with exact, pattern (`/old/*` → `/new/*`) and regex (`^/p/(\d+)$` → `/post/$1`) sources, evaluated exact, then patterns (longest literal prefix first), then regex. Options: ignore letter case, match with and without a trailing slash, ignore or pass the query string; a source with `?` matches its query exactly. Regex sources use the standard library (RE2, linear time) and are capped at 512 characters. A capture never turns a path target into another host, and an absolute target keeps its own host.
|
||||
- **Redirects** (`golem15_seo_redirects`): 301, 302 and 410 rules with exact, pattern (`/old/*` → `/new/*`) and regex (`^/p/(\d+)$` → `/post/$1`) sources, evaluated exact, then patterns (longest literal prefix first), then regex. Options: ignore letter case, match with and without a trailing slash, ignore or pass the query string; a source with `?` matches its query exactly and wins over the same path without a query. Regex sources use the standard library (RE2, linear time) and are capped at 512 characters. A capture never turns a path target into another host, and an absolute target keeps its own host.
|
||||
- **Loops and chains.** Every redirect write runs a simulation with the rule in place (a probe URL for patterns and regex, at most 10 hops): a loop is refused, from the admin with `redirects.loop` on the target and nothing saved; a chain is saved with `redirects.chain_notice` naming the final target and is marked in the list.
|
||||
- **Address changes.** When a published record's address changes, a 301 from the old to the new address is created, earlier redirects to the old address are pointed straight at the new one, and a redirect whose source is the new address is removed (a slug changed back and forth ends with one redirect per old address). In the admin this happens inside the save transaction and the editor gets `redirects.auto_created` on the slug field; writes outside the admin (Journal's API, MCP, imports) are handled after commit through `pact.RecordSaved`. Deleting or unpublishing a record creates nothing.
|
||||
- **404 log** (`golem15_seo_notfound`): the frontend reports each 404 to `POST /_seo/api/v1/notfound`; the log keeps the address, hits, first seen, last hit and last referer. An entry is resolved with one click: **Create redirect…** (a prompted 301), **Mark as gone** (410), or a bulk 410.
|
||||
|
||||
@@ -27,6 +27,27 @@ const rdSite = "https://example.test"
|
||||
// (the database writes of AddressChanged are covered by TestSlugChange301
|
||||
// and TestAddressChangeFromEvent in the plugin package).
|
||||
func TestRedirectGraph(t *testing.T) {
|
||||
t.Run("a source with a query wins over the path-only source", func(t *testing.T) {
|
||||
for _, rules := range [][]models.Redirect{
|
||||
{rd(1, "/products", "/shop"), rd(2, "/products?id=5", "/shop/item-5")},
|
||||
{rd(1, "/products?id=5", "/shop/item-5"), rd(2, "/products", "/shop")},
|
||||
} {
|
||||
set := NewRedirectSet(rules)
|
||||
hit, ok, err := set.Match("/products", "id=5")
|
||||
if err != nil || !ok || hit.Target != "/shop/item-5" {
|
||||
t.Fatalf("rules %v: /products?id=5 = %+v, %v, %v; want /shop/item-5", rules, hit, ok, err)
|
||||
}
|
||||
hit, ok, err = set.Match("/products", "id=6")
|
||||
if err != nil || !ok || hit.Target != "/shop" {
|
||||
t.Fatalf("rules %v: /products?id=6 = %+v, %v, %v; want /shop", rules, hit, ok, err)
|
||||
}
|
||||
}
|
||||
check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/products", "/shop")}, rd(0, "/products?id=5", "/shop/item-5"))
|
||||
if err != nil || check.Loop || check.Chain {
|
||||
t.Fatalf("check = %+v, %v", check, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a chain A to B to C is a chain notice from A to C", func(t *testing.T) {
|
||||
stored := []models.Redirect{rd(1, "/b", "/c")}
|
||||
check, err := checkRedirectRules(rdSite, stored, rd(0, "/a", "/b"))
|
||||
|
||||
@@ -129,13 +129,15 @@ func exactKey(source string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// exactKeys are the lookup keys a request path and query may match.
|
||||
// exactKeys are the lookup keys a request path and query may match, the
|
||||
// most specific first: a source with the request's query wins over the
|
||||
// path-only source (WR-08).
|
||||
func exactKeys(path, rawQuery string) []string {
|
||||
keys := []string{strings.ToLower(trimSlash(path))}
|
||||
key := strings.ToLower(trimSlash(path))
|
||||
if rawQuery != "" {
|
||||
keys = append(keys, keys[0]+"?"+rawQuery)
|
||||
return []string{key + "?" + rawQuery, key}
|
||||
}
|
||||
return keys
|
||||
return []string{key}
|
||||
}
|
||||
|
||||
// exactMatches reports whether an exact rule matches the request: the
|
||||
|
||||
Reference in New Issue
Block a user