Add redirect support to the server
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 27 May 2020 11:50:13 +0000 (13:50 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 28 May 2020 14:25:34 +0000 (16:25 +0200)
Fixes #7323

commands/commandeer.go
commands/server.go
config/commonConfig.go
config/commonConfig_test.go
docs/content/en/getting-started/configuration.md

index 69418b299099b8b71c7737d2317a49a66a5094f9..52a47484f39988c711fa4abfd36ac5318f4329d0 100644 (file)
@@ -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")
 
index f8370107f1532013c2f840dff30826b650539040..ee3d25a418d7afdf2064bfbb7034ffcef9b4e7c5 100644 (file)
@@ -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)
                })
        }
index ba99260a5e866593fa17c2e26e30deb2ec3f9070..6444c03ad266bd1aef6b75ba14ad920d21ae6cf4 100644 (file)
@@ -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
 }
index 41b2721bc4688d0d3b81af219759a1ba8f311e14..b8b6e6795eedd6a8e8d3ab27540b8f698cb7e1e5 100644 (file)
@@ -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))
+
+       }
+
 }
index abce0286d19d2bc630f5a3fb0dd807b52a474ecb..be46870d60042f657caad9d73252ee0f3b220af3 100644 (file)
@@ -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