diff --git a/commands/commandeer.go b/commands/commandeer.go index 69418b299..52a47484f 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -346,7 +346,10 @@ func (c *commandeer) loadConfig(mustHaveConfigFile, running bool) error { cfg.Logger = logger c.logger = logger - c.serverConfig = hconfig.DecodeServer(cfg.Cfg) + c.serverConfig, err = hconfig.DecodeServer(cfg.Cfg) + if err != nil { + return err + } createMemFs := config.GetBool("renderToMemory") diff --git a/commands/server.go b/commands/server.go index f8370107f..ee3d25a41 100644 --- a/commands/server.go +++ b/commands/server.go @@ -292,6 +292,18 @@ type fileServer struct { s *serverCmd } +func (f *fileServer) rewriteRequest(r *http.Request, toPath string) *http.Request { + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + r2.URL.Path = toPath + r2.Header.Set("X-Rewrite-Original-URI", r.URL.RequestURI()) + + return r2 + +} + func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, error) { baseURL := f.baseURLs[i] root := f.roots[i] @@ -356,10 +368,25 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, erro w.Header().Set("Pragma", "no-cache") } - for _, header := range f.c.serverConfig.Match(r.RequestURI) { + for _, header := range f.c.serverConfig.MatchHeaders(r.RequestURI) { w.Header().Set(header.Key, header.Value) } + if redirect := f.c.serverConfig.MatchRedirect(r.RequestURI); !redirect.IsZero() { + // This matches Netlify's behaviour and is needed for SPA behaviour. + // See https://docs.netlify.com/routing/redirects/rewrites-proxies/ + if redirect.Status == 200 { + if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, u.Path)); r2 != nil { + r = r2 + } + } else { + w.Header().Set("Content-Type", "") + http.Redirect(w, r, redirect.To, redirect.Status) + return + } + + } + if f.c.fastRenderMode && f.c.buildErr == nil { p := strings.TrimSuffix(r.RequestURI, "?"+r.URL.RawQuery) @@ -379,6 +406,7 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, erro } } + h.ServeHTTP(w, r) }) } diff --git a/config/commonConfig.go b/config/commonConfig.go index ba99260a5..6444c03ad 100644 --- a/config/commonConfig.go +++ b/config/commonConfig.go @@ -14,6 +14,8 @@ package config import ( + "github.com/pkg/errors" + "sort" "strings" "sync" @@ -101,26 +103,36 @@ func DecodeSitemap(prototype Sitemap, input map[string]interface{}) Sitemap { // Config for the dev server. type Server struct { - Headers []Headers + Headers []Headers + Redirects []Redirect - compiledInit sync.Once - compiled []glob.Glob + compiledInit sync.Once + compiledHeaders []glob.Glob + compiledRedirects []glob.Glob } -func (s *Server) Match(pattern string) []types.KeyValueStr { +func (s *Server) init() { + s.compiledInit.Do(func() { for _, h := range s.Headers { - s.compiled = append(s.compiled, glob.MustCompile(h.For)) + s.compiledHeaders = append(s.compiledHeaders, glob.MustCompile(h.For)) + } + for _, r := range s.Redirects { + s.compiledRedirects = append(s.compiledRedirects, glob.MustCompile(r.From)) } }) +} - if s.compiled == nil { +func (s *Server) MatchHeaders(pattern string) []types.KeyValueStr { + s.init() + + if s.compiledHeaders == nil { return nil } var matches []types.KeyValueStr - for i, g := range s.compiled { + for i, g := range s.compiledHeaders { if g.Match(pattern) { h := s.Headers[i] for k, v := range h.Values { @@ -137,18 +149,67 @@ func (s *Server) Match(pattern string) []types.KeyValueStr { } +func (s *Server) MatchRedirect(pattern string) Redirect { + s.init() + + if s.compiledRedirects == nil { + return Redirect{} + } + + pattern = strings.TrimSuffix(pattern, "index.html") + + for i, g := range s.compiledRedirects { + redir := s.Redirects[i] + + // No redirect to self. + if redir.To == pattern { + return Redirect{} + } + + if g.Match(pattern) { + return redir + } + } + + return Redirect{} + +} + type Headers struct { For string Values map[string]interface{} } -func DecodeServer(cfg Provider) *Server { +type Redirect struct { + From string + To string + Status int +} + +func (r Redirect) IsZero() bool { + return r.From == "" +} + +func DecodeServer(cfg Provider) (*Server, error) { m := cfg.GetStringMap("server") s := &Server{} if m == nil { - return s + return s, nil } _ = mapstructure.WeakDecode(m, s) - return s + + for i, redir := range s.Redirects { + // Get it in line with the Hugo server. + 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 nil, errors.Errorf("unspported 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) + } + s.Redirects[i] = redir + } + + return s, nil } diff --git a/config/commonConfig_test.go b/config/commonConfig_test.go index 41b2721bc..b8b6e6795 100644 --- a/config/commonConfig_test.go +++ b/config/commonConfig_test.go @@ -70,15 +70,73 @@ for = "/*.jpg" X-Frame-Options = "DENY" X-XSS-Protection = "1; mode=block" X-Content-Type-Options = "nosniff" + +[[server.redirects]] +from = "/foo/**" +to = "/foo/index.html" +status = 200 + +[[server.redirects]] +from = "/google/**" +to = "https://google.com/" +status = 301 + +[[server.redirects]] +from = "/**" +to = "/default/index.html" +status = 301 + + + `, "toml") c.Assert(err, qt.IsNil) - s := DecodeServer(cfg) + s, err := DecodeServer(cfg) + c.Assert(err, qt.IsNil) - c.Assert(s.Match("/foo.jpg"), qt.DeepEquals, []types.KeyValueStr{ + c.Assert(s.MatchHeaders("/foo.jpg"), qt.DeepEquals, []types.KeyValueStr{ {Key: "X-Content-Type-Options", Value: "nosniff"}, {Key: "X-Frame-Options", Value: "DENY"}, {Key: "X-XSS-Protection", Value: "1; mode=block"}}) + c.Assert(s.MatchRedirect("/foo/bar/baz"), qt.DeepEquals, Redirect{ + From: "/foo/**", + To: "/foo/", + Status: 200, + }) + + c.Assert(s.MatchRedirect("/someother"), qt.DeepEquals, Redirect{ + From: "/**", + To: "/default/", + Status: 301, + }) + + c.Assert(s.MatchRedirect("/google/foo"), 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)) + + } + } diff --git a/docs/content/en/getting-started/configuration.md b/docs/content/en/getting-started/configuration.md index abce0286d..be46870d6 100644 --- a/docs/content/en/getting-started/configuration.md +++ b/docs/content/en/getting-started/configuration.md @@ -349,6 +349,20 @@ Content-Security-Policy = "script-src localhost:1313" {{< /code-toggle >}} +{{< new-in "0.72.0" >}} + +You can also specify simple redirects rules for the server. The syntax is again similar to Netlify's. + +Note that a `status` code of 200 will trigger a [URL rewrite](https://docs.netlify.com/routing/redirects/rewrites-proxies/), which is what you want in SPA situations, e.g: + +{{< code-toggle file="config/development/server">}} +[[redirects]] +from = "/myspa/**" +to = "/myspa/" +status = 200 +{{< /code-toggle >}} + + ## Configure Title Case