diff --git a/commands/server.go b/commands/server.go
index 08ecd5bac..828c78a3a 100644
--- a/commands/server.go
+++ b/commands/server.go
@@ -84,6 +84,10 @@ const (
configChangeGoWork = "go work file"
)
+const (
+ hugoHeaderRedirect = "X-Hugo-Redirect"
+)
+
func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder {
var visitedURLs *types.EvictingQueue[string]
if s != nil && !s.disableFastRender {
@@ -307,67 +311,65 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string
w.Header().Set(header.Key, header.Value)
}
- if redirect := serverConfig.MatchRedirect(requestURI); !redirect.IsZero() {
- // fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
- doRedirect := true
- // This matches Netlify's behavior and is needed for SPA behavior.
- // See https://docs.netlify.com/routing/redirects/rewrites-proxies/
- if !redirect.Force {
- path := filepath.Clean(strings.TrimPrefix(requestURI, baseURL.Path()))
- if root != "" {
- path = filepath.Join(root, path)
- }
- var fs afero.Fs
- f.c.withConf(func(conf *commonConfig) {
- fs = conf.fs.PublishDirServer
- })
-
- fi, err := fs.Stat(path)
-
- if err == nil {
- if fi.IsDir() {
- // There will be overlapping directories, so we
- // need to check for a file.
- _, err = fs.Stat(filepath.Join(path, "index.html"))
- doRedirect = err != nil
- } else {
- doRedirect = false
+ if canRedirect(requestURI, r) {
+ if redirect := serverConfig.MatchRedirect(requestURI, r.Header); !redirect.IsZero() {
+ doRedirect := true
+ // This matches Netlify's behavior and is needed for SPA behavior.
+ // See https://docs.netlify.com/routing/redirects/rewrites-proxies/
+ if !redirect.Force {
+ path := filepath.Clean(strings.TrimPrefix(requestURI, baseURL.Path()))
+ if root != "" {
+ path = filepath.Join(root, path)
}
- }
- }
+ var fs afero.Fs
+ f.c.withConf(func(conf *commonConfig) {
+ fs = conf.fs.PublishDirServer
+ })
+
+ fi, err := fs.Stat(path)
- if doRedirect {
- switch redirect.Status {
- case 404:
- w.WriteHeader(404)
- file, err := fs.Open(strings.TrimPrefix(redirect.To, baseURL.Path()))
if err == nil {
- defer file.Close()
- io.Copy(w, file)
- } else {
- fmt.Fprintln(w, "
Page Not Found
")
+ if fi.IsDir() {
+ // There will be overlapping directories, so we
+ // need to check for a file.
+ _, err = fs.Stat(filepath.Join(path, "index.html"))
+ doRedirect = err != nil
+ } else {
+ doRedirect = false
+ }
}
- return
- case 200:
- if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, baseURL.Path())); r2 != nil {
- requestURI = redirect.To
- r = r2
- }
- default:
- w.Header().Set("Content-Type", "")
- http.Redirect(w, r, redirect.To, redirect.Status)
- return
+ }
+ if doRedirect {
+ w.Header().Set(hugoHeaderRedirect, "true")
+ switch redirect.Status {
+ case 404:
+ w.WriteHeader(404)
+ file, err := fs.Open(strings.TrimPrefix(redirect.To, baseURL.Path()))
+ if err == nil {
+ defer file.Close()
+ io.Copy(w, file)
+ } else {
+ fmt.Fprintln(w, "Page Not Found
")
+ }
+ return
+ case 200:
+ if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, baseURL.Path())); r2 != nil {
+ requestURI = redirect.To
+ r = r2
+ }
+ default:
+ w.Header().Set("Content-Type", "")
+ http.Redirect(w, r, redirect.To, redirect.Status)
+ return
+
+ }
}
}
-
}
if f.c.fastRenderMode && f.c.errState.buildErr() == nil {
- // Sec-Fetch-Mode should be sent by all recent browser versions, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode#navigate
- // Fall back to the file extension if not set.
- // The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
- if r.Header.Get("Sec-Fetch-Mode") == "navigate" || strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
+ if isNavigation(requestURI, r) {
if !f.c.visitedURLs.Contains(requestURI) {
// If not already on stack, re-render that single page.
if err := f.c.partialReRender(requestURI); err != nil {
@@ -1233,3 +1235,24 @@ func formatByteCount(b uint64) string {
return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp])
}
+
+func canRedirect(requestURIWithoutQuery string, r *http.Request) bool {
+ if r.Header.Get(hugoHeaderRedirect) != "" {
+ return false
+ }
+ return isNavigation(requestURIWithoutQuery, r)
+}
+
+// Sec-Fetch-Mode should be sent by all recent browser versions, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode#navigate
+// Fall back to the file extension if not set.
+// The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
+func isNavigation(requestURIWithoutQuery string, r *http.Request) bool {
+ return r.Header.Get("Sec-Fetch-Mode") == "navigate" || isPropablyHTMLRequest(requestURIWithoutQuery)
+}
+
+func isPropablyHTMLRequest(requestURIWithoutQuery string) bool {
+ if strings.HasSuffix(requestURIWithoutQuery, "/") || strings.HasSuffix(requestURIWithoutQuery, "html") || strings.HasSuffix(requestURIWithoutQuery, "htm") {
+ return true
+ }
+ return !strings.Contains(requestURIWithoutQuery, ".")
+}
diff --git a/config/commonConfig.go b/config/commonConfig.go
index 9dea4a2fc..9125f256a 100644
--- a/config/commonConfig.go
+++ b/config/commonConfig.go
@@ -15,6 +15,7 @@ package config
import (
"fmt"
+ "net/http"
"regexp"
"sort"
"strings"
@@ -226,7 +227,22 @@ type Server struct {
Redirects []Redirect
compiledHeaders []glob.Glob
- compiledRedirects []glob.Glob
+ compiledRedirects []redirect
+}
+
+type redirect struct {
+ from glob.Glob
+ fromRe *regexp.Regexp
+ headers map[string]glob.Glob
+}
+
+func (r redirect) matchHeader(header http.Header) bool {
+ for k, v := range r.headers {
+ if !v.Match(header.Get(k)) {
+ return false
+ }
+ }
+ return true
}
func (s *Server) CompileConfig(logger loggers.Logger) error {
@@ -234,10 +250,41 @@ func (s *Server) CompileConfig(logger loggers.Logger) error {
return nil
}
for _, h := range s.Headers {
- s.compiledHeaders = append(s.compiledHeaders, glob.MustCompile(h.For))
+ g, err := glob.Compile(h.For)
+ if err != nil {
+ return fmt.Errorf("failed to compile Headers glob %q: %w", h.For, err)
+ }
+ s.compiledHeaders = append(s.compiledHeaders, g)
}
for _, r := range s.Redirects {
- s.compiledRedirects = append(s.compiledRedirects, glob.MustCompile(r.From))
+ if r.From == "" && r.FromRe == "" {
+ return fmt.Errorf("redirects must have either From or FromRe set")
+ }
+ rd := redirect{
+ headers: make(map[string]glob.Glob),
+ }
+ if r.From != "" {
+ g, err := glob.Compile(r.From)
+ if err != nil {
+ return fmt.Errorf("failed to compile Redirect glob %q: %w", r.From, err)
+ }
+ rd.from = g
+ }
+ if r.FromRe != "" {
+ re, err := regexp.Compile(r.FromRe)
+ if err != nil {
+ return fmt.Errorf("failed to compile Redirect regexp %q: %w", r.FromRe, err)
+ }
+ rd.fromRe = re
+ }
+ for k, v := range r.FromHeaders {
+ g, err := glob.Compile(v)
+ if err != nil {
+ return fmt.Errorf("failed to compile Redirect header glob %q: %w", v, err)
+ }
+ rd.headers[k] = g
+ }
+ s.compiledRedirects = append(s.compiledRedirects, rd)
}
return nil
@@ -266,22 +313,42 @@ func (s *Server) MatchHeaders(pattern string) []types.KeyValueStr {
return matches
}
-func (s *Server) MatchRedirect(pattern string) Redirect {
+func (s *Server) MatchRedirect(pattern string, header http.Header) Redirect {
if s.compiledRedirects == nil {
return Redirect{}
}
pattern = strings.TrimSuffix(pattern, "index.html")
- for i, g := range s.compiledRedirects {
+ for i, r := range s.compiledRedirects {
redir := s.Redirects[i]
- // No redirect to self.
- if redir.To == pattern {
- return Redirect{}
+ var found bool
+
+ if r.from != nil {
+ if r.from.Match(pattern) {
+ found = header == nil || r.matchHeader(header)
+ // We need to do regexp group replacements if needed.
+ }
}
- if g.Match(pattern) {
+ if r.fromRe != nil {
+ m := r.fromRe.FindStringSubmatch(pattern)
+ if m != nil {
+ if !found {
+ found = header == nil || r.matchHeader(header)
+ }
+
+ if found {
+ // Replace $1, $2 etc. in To.
+ for i, g := range m[1:] {
+ redir.To = strings.ReplaceAll(redir.To, fmt.Sprintf("$%d", i+1), g)
+ }
+ }
+ }
+ }
+
+ if found {
return redir
}
}
@@ -295,8 +362,22 @@ type Headers struct {
}
type Redirect struct {
+ // From is the Glob pattern to match.
+ // One of From or FromRe must be set.
From string
- To string
+
+ // FromRe is the regexp to match.
+ // This regexp can contain group matches (e.g. $1) that can be used in the To field.
+ // One of From or FromRe must be set.
+ FromRe string
+
+ // To is the target URL.
+ To string
+
+ // Headers to match for the redirect.
+ // This maps the HTTP header name to a Glob pattern with values to match.
+ // If the map is empty, the redirect will always be triggered.
+ FromHeaders map[string]string
// HTTP status code to use for the redirect.
// A status code of 200 will trigger a URL rewrite.
@@ -383,17 +464,7 @@ func DecodeServer(cfg Provider) (Server, error) {
_ = mapstructure.WeakDecode(cfg.GetStringMap("server"), s)
for i, redir := range s.Redirects {
- // Get it in line with the Hugo server for OK responses.
- // We currently treat the 404 as a special case, they are always "ugly", so keep them as is.
- if redir.Status != 404 {
- redir.To = strings.TrimSuffix(redir.To, "index.html")
- if !strings.HasPrefix(redir.To, "https") && !strings.HasSuffix(redir.To, "/") {
- // There are some tricky infinite loop situations when dealing
- // when the target does not have a trailing slash.
- // This can certainly be handled better, but not time for that now.
- return Server{}, fmt.Errorf("unsupported redirect to value %q in server config; currently this must be either a remote destination or a local folder, e.g. \"/blog/\" or \"/blog/index.html\"", redir.To)
- }
- }
+ redir.To = strings.TrimSuffix(redir.To, "index.html")
s.Redirects[i] = redir
}
@@ -401,7 +472,7 @@ func DecodeServer(cfg Provider) (Server, error) {
// Set up a default redirect for 404s.
s.Redirects = []Redirect{
{
- From: "**",
+ From: "/**",
To: "/404.html",
Status: 404,
},
diff --git a/config/commonConfig_test.go b/config/commonConfig_test.go
index 425d3e970..ce68cec15 100644
--- a/config/commonConfig_test.go
+++ b/config/commonConfig_test.go
@@ -71,7 +71,28 @@ X-Content-Type-Options = "nosniff"
[[server.redirects]]
from = "/foo/**"
-to = "/foo/index.html"
+to = "/baz/index.html"
+status = 200
+
+[[server.redirects]]
+from = "/loop/**"
+to = "/loop/foo/"
+status = 200
+
+[[server.redirects]]
+from = "/b/**"
+fromRe = "/b/(.*)/"
+to = "/baz/$1/"
+status = 200
+
+[[server.redirects]]
+fromRe = "/c/(.*)/"
+to = "/boo/$1/"
+status = 200
+
+[[server.redirects]]
+fromRe = "/d/(.*)/"
+to = "/boo/$1/"
status = 200
[[server.redirects]]
@@ -79,11 +100,6 @@ from = "/google/**"
to = "https://google.com/"
status = 301
-[[server.redirects]]
-from = "/**"
-to = "/default/index.html"
-status = 301
-
`, "toml")
@@ -100,45 +116,35 @@ status = 301
{Key: "X-XSS-Protection", Value: "1; mode=block"},
})
- c.Assert(s.MatchRedirect("/foo/bar/baz"), qt.DeepEquals, Redirect{
+ c.Assert(s.MatchRedirect("/foo/bar/baz", nil), qt.DeepEquals, Redirect{
From: "/foo/**",
- To: "/foo/",
+ To: "/baz/",
Status: 200,
})
- c.Assert(s.MatchRedirect("/someother"), qt.DeepEquals, Redirect{
- From: "/**",
- To: "/default/",
- Status: 301,
+ c.Assert(s.MatchRedirect("/foo/bar/", nil), qt.DeepEquals, Redirect{
+ From: "/foo/**",
+ To: "/baz/",
+ Status: 200,
})
- c.Assert(s.MatchRedirect("/google/foo"), qt.DeepEquals, Redirect{
+ c.Assert(s.MatchRedirect("/b/c/", nil), qt.DeepEquals, Redirect{
+ From: "/b/**",
+ FromRe: "/b/(.*)/",
+ To: "/baz/c/",
+ Status: 200,
+ })
+
+ c.Assert(s.MatchRedirect("/c/d/", nil).To, qt.Equals, "/boo/d/")
+ c.Assert(s.MatchRedirect("/c/d/e/", nil).To, qt.Equals, "/boo/d/e/")
+
+ c.Assert(s.MatchRedirect("/someother", nil), qt.DeepEquals, Redirect{})
+
+ c.Assert(s.MatchRedirect("/google/foo", nil), qt.DeepEquals, Redirect{
From: "/google/**",
To: "https://google.com/",
Status: 301,
})
-
- // No redirect loop, please.
- c.Assert(s.MatchRedirect("/default/index.html"), qt.DeepEquals, Redirect{})
- c.Assert(s.MatchRedirect("/default/"), qt.DeepEquals, Redirect{})
-
- for _, errorCase := range []string{
- `[[server.redirects]]
-from = "/**"
-to = "/file"
-status = 301`,
- `[[server.redirects]]
-from = "/**"
-to = "/foo/file.html"
-status = 301`,
- } {
-
- cfg, err := FromConfigString(errorCase, "toml")
- c.Assert(err, qt.IsNil)
- _, err = DecodeServer(cfg)
- c.Assert(err, qt.Not(qt.IsNil))
-
- }
}
func TestBuildConfigCacheBusters(t *testing.T) {