feat: URL normalisation, strict URLs and the one-hop redirect decision
- Normalize: https, www or bare host, double slashes and index files, trailing slash add/remove, optional ASCII lowercasing (defaults change nothing but scheme, slashes and index files) - strict URLs through signpost MatchPath: a path that names a record without being its address 301s to the record's address - resolve runs normalisation, redirects, strict URLs, then SEO; the location is the final address, so a visitor makes one hop - P1 warns when a record's canonical URL redirects
This commit is contained in:
@@ -218,26 +218,6 @@ func parseTarget(raw string) (string, url.Values, error) {
|
||||
return path, u.Query(), nil
|
||||
}
|
||||
|
||||
// lookup finds the published record behind path; key is "" when none.
|
||||
func (r *Resolver) lookup(ctx context.Context, path string) (key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, err error) {
|
||||
reg := r.Service.Registry()
|
||||
if reg == nil {
|
||||
return "", nil, nil, nil
|
||||
}
|
||||
key, match, err = reg.MatchPath(ctx, path, r.Service.UnprefixedLocale())
|
||||
if err != nil || match == nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
rec, err = reg.Describe(ctx, key, match.ID, r.Service.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if rec == nil || !rec.Published {
|
||||
return "", nil, nil, nil
|
||||
}
|
||||
return key, match, rec, nil
|
||||
}
|
||||
|
||||
// contentLocale returns the record's description in locale, else its first
|
||||
// locale.
|
||||
func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale {
|
||||
@@ -339,19 +319,33 @@ func resolveRobots(layers []robotsLayer) (Robots, string) {
|
||||
return out.Inherit(Robots{Index: "index", Follow: "follow"}), source
|
||||
}
|
||||
|
||||
// Resolve answers the effective SEO of rawURL, a path or an absolute URL of
|
||||
// the site, with the D-02 precedence per value (P-14): URL rule > record >
|
||||
// per-type template or global settings > content-derived values. A path
|
||||
// with neither a published record nor a URL rule answers not_found, except
|
||||
// the home page. Outside production every answer is noindex,nofollow with
|
||||
// staging true (D-22). Absolute URLs come from SiteURL, never from a
|
||||
// request.
|
||||
// Resolve answers rawURL, a path or an absolute URL of the site: the
|
||||
// redirect decision first, then the effective SEO (D-20a, D-103, P-21).
|
||||
// The pipeline is:
|
||||
// 1. normalisation (D-20b): every address rule at once;
|
||||
// 2. redirects on the normalised path, followed to the final target
|
||||
// (a 410 answers gone, a cycle not_found);
|
||||
// 3. strict URLs (D-20c): a path that names a record without being its
|
||||
// address 301s to the record's address;
|
||||
// 4. the effective SEO with the D-02 precedence per value (P-14): URL rule
|
||||
// > record > per-type template or global settings > content-derived
|
||||
// values.
|
||||
//
|
||||
// When normalisation changed the URL or a redirect matched, the answer's
|
||||
// location is the final address, so a visitor makes one hop. A path with
|
||||
// neither a published record nor a URL rule answers not_found, except the
|
||||
// home page. Outside production every answer is noindex,nofollow with
|
||||
// staging true (D-22). Absolute URLs come from SiteURL, or from the
|
||||
// request's own URL when that is the site's host or its www twin; never
|
||||
// from a request Host header.
|
||||
func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
|
||||
path, query, err := parseTarget(rawURL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
rawQuery := rawQueryOf(rawURL)
|
||||
svc := r.Service
|
||||
site := svc.SiteURL()
|
||||
db, err := svc.DB(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
@@ -360,22 +354,80 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
opts := NormalizeOptionsOf(settings)
|
||||
self := site + joinQuery(path, rawQuery)
|
||||
|
||||
// 1. Normalisation. An absolute URL of the site (or its www twin)
|
||||
// keeps its scheme and host for the scheme and host rules; any other
|
||||
// host is ignored.
|
||||
in := &url.URL{Path: path, RawQuery: rawQuery}
|
||||
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.IsAbs() && sameSite(site, u.Host) {
|
||||
in.Scheme, in.Host = u.Scheme, u.Host
|
||||
}
|
||||
norm, changed := Normalize(in, opts)
|
||||
origin := site
|
||||
if norm.Host != "" {
|
||||
origin = norm.Scheme + "://" + norm.Host
|
||||
}
|
||||
npath, nquery := norm.Path, norm.RawQuery
|
||||
|
||||
// 2. Redirects on the normalised path.
|
||||
set, err := LoadRedirectSet(db)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
chain, err := set.Follow(svc.SiteURL(), path, rawQueryOf(rawURL), MaxRedirectHops)
|
||||
chain, err := set.Follow(site, npath, nquery, MaxRedirectHops)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if len(chain.Hops) > 0 {
|
||||
return r.redirectAnswer(svc.SiteURL()+joinQuery(path, rawQueryOf(rawURL)), chain), nil
|
||||
if chain.Loop || chain.Gone {
|
||||
return r.redirectAnswer(self, chain), nil
|
||||
}
|
||||
location := chain.Final
|
||||
if fpath, fquery, internal := internalTarget(site, chain.Final); internal {
|
||||
final, _ := Normalize(&url.URL{Path: fpath, RawQuery: fquery}, opts)
|
||||
fpath = final.Path
|
||||
st, err := r.Strict(ctx, fpath, opts)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if st.Key != "" && st.Mismatch && st.Canonical != "" {
|
||||
fpath = st.Canonical
|
||||
}
|
||||
location = origin + joinQuery(escapePath(fpath), final.RawQuery)
|
||||
}
|
||||
res := Result{Action: ActionRedirect, Status: chain.Status, Location: location, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}
|
||||
return res, nil
|
||||
}
|
||||
key, match, rec, err := r.lookup(ctx, path)
|
||||
|
||||
// 3. Strict URLs.
|
||||
st, err := r.Strict(ctx, npath, opts)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return r.resolveAt(ctx, db, settings, path, query, rawQueryOf(rawURL), key, match, rec, false)
|
||||
if st.Key != "" && st.Mismatch {
|
||||
if st.Canonical == "" {
|
||||
return Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}, nil
|
||||
}
|
||||
return r.moved(self, origin+joinQuery(escapePath(st.Canonical), nquery)), nil
|
||||
}
|
||||
if changed {
|
||||
return r.moved(self, origin+joinQuery(escapePath(npath), nquery)), nil
|
||||
}
|
||||
|
||||
// 4. The effective SEO.
|
||||
return r.resolveAt(ctx, db, settings, npath, query, nquery, st.Key, st.Match, st.Record, false)
|
||||
}
|
||||
|
||||
// moved is a 301 answer to location.
|
||||
func (r *Resolver) moved(self, location string) Result {
|
||||
return Result{Action: ActionRedirect, Status: int(models.Status301), Location: location, URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}}
|
||||
}
|
||||
|
||||
// escapePath percent-encodes a decoded path for a Location.
|
||||
func escapePath(p string) string {
|
||||
return (&url.URL{Path: p}).EscapedPath()
|
||||
}
|
||||
|
||||
// ResolveRecord answers the effective SEO of a record in one locale at its
|
||||
@@ -571,21 +623,17 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// redirectAnswer is the answer of a matched redirect chain (P-21): gone
|
||||
// for a chain that ends on a 410, not_found (logged once per URL) for a
|
||||
// cycle, else redirect with the chain's status and its final target
|
||||
// absolute on the site URL, so the visitor makes one hop.
|
||||
// redirectAnswer is the answer of a redirect chain that leads nowhere:
|
||||
// gone for a chain that ends on a 410, not_found (logged once per URL) for
|
||||
// a cycle (P-22).
|
||||
func (r *Resolver) redirectAnswer(self string, ch Chain) Result {
|
||||
res := Result{URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}}
|
||||
switch {
|
||||
case ch.Loop:
|
||||
logRedirectCycle(self, ch)
|
||||
res.Action, res.Status = ActionNotFound, 404
|
||||
case ch.Gone:
|
||||
if ch.Gone && !ch.Loop {
|
||||
res.Action, res.Status = ActionGone, int(models.Status410)
|
||||
default:
|
||||
res.Action, res.Status, res.Location = ActionRedirect, ch.Status, AbsoluteURL(r.Service.SiteURL(), ch.Final)
|
||||
return res
|
||||
}
|
||||
logRedirectCycle(self, ch)
|
||||
res.Action, res.Status = ActionNotFound, 404
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user