shithub: hugo

Download patch

ref: 6a3e89743ccad58097a6dd203a63448946a2304d
parent: 9613e3e8a81be934fc88db3f9f0d79d429aae1a2
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Wed May 27 09:50:13 EDT 2020

Add redirect support to the server

Fixes #7323

--- a/commands/commandeer.go
+++ b/commands/commandeer.go
@@ -346,7 +346,10 @@
 
 	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")
 
--- a/commands/server.go
+++ b/commands/server.go
@@ -292,6 +292,18 @@
 	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 @@
 				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 @@
 
 				}
 			}
+
 			h.ServeHTTP(w, r)
 		})
 	}
--- 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 @@
 
 // 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) 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
 }
--- a/config/commonConfig_test.go
+++ b/config/commonConfig_test.go
@@ -70,15 +70,73 @@
 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))
+
+	}
 
 }
--- a/docs/content/en/getting-started/configuration.md
+++ b/docs/content/en/getting-started/configuration.md
@@ -349,6 +349,20 @@
 {{< /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