Skip to content

Commit 029d1e0

Browse files
committed
Add some more server options/improvements
New options: * `FromHeaders`: Server header matching for redirects * `FromRe`: Regexp with group support, i.e. it replaces $1, $2 in To with the group matches. Note that if both `From` and `FromRe` is set, both must match. Also * Allow redirects to non HTML URLs as long as the Sec-Fetch-Mode is set to navigate on the request. * Detect and stop redirect loops. This was all done while testing out InertiaJS with Hugo. So, after this commit, this setup will support the main parts of the protocol that Inertia uses: ```toml [server] [[server.headers]] for = '/**/inertia.json' [server.headers.values] Content-Type = 'text/html' X-Inertia = 'true' Vary = 'Accept' [[server.redirects]] force = true from = '/**/' fromRe = "^/(.*)/$" fromHeaders = { "X-Inertia" = "true" } status = 301 to = '/$1/inertia.json' ``` Unfortunately, a provider like Netlify does not support redirects matching by request headers. It should be possible with some edge function, but then again, I'm not sure that InertiaJS is a very good fit with the common Hugo use cases. But this commit should be generally useful.
1 parent e865d59 commit 029d1e0

File tree

3 files changed

+207
-107
lines changed

3 files changed

+207
-107
lines changed

commands/server.go

+73-50
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ const (
8484
configChangeGoWork = "go work file"
8585
)
8686

87+
const (
88+
hugoHeaderRedirect = "X-Hugo-Redirect"
89+
)
90+
8791
func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder {
8892
var visitedURLs *types.EvictingQueue[string]
8993
if s != nil && !s.disableFastRender {
@@ -307,67 +311,65 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string
307311
w.Header().Set(header.Key, header.Value)
308312
}
309313

310-
if redirect := serverConfig.MatchRedirect(requestURI); !redirect.IsZero() {
311-
// fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
312-
doRedirect := true
313-
// This matches Netlify's behavior and is needed for SPA behavior.
314-
// See https://docs.netlify.com/routing/redirects/rewrites-proxies/
315-
if !redirect.Force {
316-
path := filepath.Clean(strings.TrimPrefix(requestURI, baseURL.Path()))
317-
if root != "" {
318-
path = filepath.Join(root, path)
319-
}
320-
var fs afero.Fs
321-
f.c.withConf(func(conf *commonConfig) {
322-
fs = conf.fs.PublishDirServer
323-
})
314+
if canRedirect(requestURI, r) {
315+
if redirect := serverConfig.MatchRedirect(requestURI, r.Header); !redirect.IsZero() {
316+
doRedirect := true
317+
// This matches Netlify's behavior and is needed for SPA behavior.
318+
// See https://docs.netlify.com/routing/redirects/rewrites-proxies/
319+
if !redirect.Force {
320+
path := filepath.Clean(strings.TrimPrefix(requestURI, baseURL.Path()))
321+
if root != "" {
322+
path = filepath.Join(root, path)
323+
}
324+
var fs afero.Fs
325+
f.c.withConf(func(conf *commonConfig) {
326+
fs = conf.fs.PublishDirServer
327+
})
324328

325-
fi, err := fs.Stat(path)
329+
fi, err := fs.Stat(path)
326330

327-
if err == nil {
328-
if fi.IsDir() {
329-
// There will be overlapping directories, so we
330-
// need to check for a file.
331-
_, err = fs.Stat(filepath.Join(path, "index.html"))
332-
doRedirect = err != nil
333-
} else {
334-
doRedirect = false
331+
if err == nil {
332+
if fi.IsDir() {
333+
// There will be overlapping directories, so we
334+
// need to check for a file.
335+
_, err = fs.Stat(filepath.Join(path, "index.html"))
336+
doRedirect = err != nil
337+
} else {
338+
doRedirect = false
339+
}
335340
}
336341
}
337-
}
338342

339-
if doRedirect {
340-
switch redirect.Status {
341-
case 404:
342-
w.WriteHeader(404)
343-
file, err := fs.Open(strings.TrimPrefix(redirect.To, baseURL.Path()))
344-
if err == nil {
345-
defer file.Close()
346-
io.Copy(w, file)
347-
} else {
348-
fmt.Fprintln(w, "<h1>Page Not Found</h1>")
349-
}
350-
return
351-
case 200:
352-
if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, baseURL.Path())); r2 != nil {
353-
requestURI = redirect.To
354-
r = r2
355-
}
356-
default:
357-
w.Header().Set("Content-Type", "")
358-
http.Redirect(w, r, redirect.To, redirect.Status)
359-
return
343+
if doRedirect {
344+
w.Header().Set(hugoHeaderRedirect, "true")
345+
switch redirect.Status {
346+
case 404:
347+
w.WriteHeader(404)
348+
file, err := fs.Open(strings.TrimPrefix(redirect.To, baseURL.Path()))
349+
if err == nil {
350+
defer file.Close()
351+
io.Copy(w, file)
352+
} else {
353+
fmt.Fprintln(w, "<h1>Page Not Found</h1>")
354+
}
355+
return
356+
case 200:
357+
if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, baseURL.Path())); r2 != nil {
358+
requestURI = redirect.To
359+
r = r2
360+
}
361+
default:
362+
w.Header().Set("Content-Type", "")
363+
http.Redirect(w, r, redirect.To, redirect.Status)
364+
return
360365

366+
}
361367
}
362368
}
363-
364369
}
365370

366371
if f.c.fastRenderMode && f.c.errState.buildErr() == nil {
367-
// 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
368-
// Fall back to the file extension if not set.
369-
// The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
370-
if r.Header.Get("Sec-Fetch-Mode") == "navigate" || strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
372+
if isNavigation(requestURI, r) {
371373
if !f.c.visitedURLs.Contains(requestURI) {
372374
// If not already on stack, re-render that single page.
373375
if err := f.c.partialReRender(requestURI); err != nil {
@@ -1233,3 +1235,24 @@ func formatByteCount(b uint64) string {
12331235
return fmt.Sprintf("%.1f %cB",
12341236
float64(b)/float64(div), "kMGTPE"[exp])
12351237
}
1238+
1239+
func canRedirect(requestURIWithoutQuery string, r *http.Request) bool {
1240+
if r.Header.Get(hugoHeaderRedirect) != "" {
1241+
return false
1242+
}
1243+
return isNavigation(requestURIWithoutQuery, r)
1244+
}
1245+
1246+
// 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
1247+
// Fall back to the file extension if not set.
1248+
// The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
1249+
func isNavigation(requestURIWithoutQuery string, r *http.Request) bool {
1250+
return r.Header.Get("Sec-Fetch-Mode") == "navigate" || isPropablyHTMLRequest(requestURIWithoutQuery)
1251+
}
1252+
1253+
func isPropablyHTMLRequest(requestURIWithoutQuery string) bool {
1254+
if strings.HasSuffix(requestURIWithoutQuery, "/") || strings.HasSuffix(requestURIWithoutQuery, "html") || strings.HasSuffix(requestURIWithoutQuery, "htm") {
1255+
return true
1256+
}
1257+
return !strings.Contains(requestURIWithoutQuery, ".")
1258+
}

config/commonConfig.go

+93-22
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package config
1515

1616
import (
1717
"fmt"
18+
"net/http"
1819
"regexp"
1920
"sort"
2021
"strings"
@@ -226,18 +227,64 @@ type Server struct {
226227
Redirects []Redirect
227228

228229
compiledHeaders []glob.Glob
229-
compiledRedirects []glob.Glob
230+
compiledRedirects []redirect
231+
}
232+
233+
type redirect struct {
234+
from glob.Glob
235+
fromRe *regexp.Regexp
236+
headers map[string]glob.Glob
237+
}
238+
239+
func (r redirect) matchHeader(header http.Header) bool {
240+
for k, v := range r.headers {
241+
if !v.Match(header.Get(k)) {
242+
return false
243+
}
244+
}
245+
return true
230246
}
231247

232248
func (s *Server) CompileConfig(logger loggers.Logger) error {
233249
if s.compiledHeaders != nil {
234250
return nil
235251
}
236252
for _, h := range s.Headers {
237-
s.compiledHeaders = append(s.compiledHeaders, glob.MustCompile(h.For))
253+
g, err := glob.Compile(h.For)
254+
if err != nil {
255+
return fmt.Errorf("failed to compile Headers glob %q: %w", h.For, err)
256+
}
257+
s.compiledHeaders = append(s.compiledHeaders, g)
238258
}
239259
for _, r := range s.Redirects {
240-
s.compiledRedirects = append(s.compiledRedirects, glob.MustCompile(r.From))
260+
if r.From == "" && r.FromRe == "" {
261+
return fmt.Errorf("redirects must have either From or FromRe set")
262+
}
263+
rd := redirect{
264+
headers: make(map[string]glob.Glob),
265+
}
266+
if r.From != "" {
267+
g, err := glob.Compile(r.From)
268+
if err != nil {
269+
return fmt.Errorf("failed to compile Redirect glob %q: %w", r.From, err)
270+
}
271+
rd.from = g
272+
}
273+
if r.FromRe != "" {
274+
re, err := regexp.Compile(r.FromRe)
275+
if err != nil {
276+
return fmt.Errorf("failed to compile Redirect regexp %q: %w", r.FromRe, err)
277+
}
278+
rd.fromRe = re
279+
}
280+
for k, v := range r.FromHeaders {
281+
g, err := glob.Compile(v)
282+
if err != nil {
283+
return fmt.Errorf("failed to compile Redirect header glob %q: %w", v, err)
284+
}
285+
rd.headers[k] = g
286+
}
287+
s.compiledRedirects = append(s.compiledRedirects, rd)
241288
}
242289

243290
return nil
@@ -266,22 +313,42 @@ func (s *Server) MatchHeaders(pattern string) []types.KeyValueStr {
266313
return matches
267314
}
268315

269-
func (s *Server) MatchRedirect(pattern string) Redirect {
316+
func (s *Server) MatchRedirect(pattern string, header http.Header) Redirect {
270317
if s.compiledRedirects == nil {
271318
return Redirect{}
272319
}
273320

274321
pattern = strings.TrimSuffix(pattern, "index.html")
275322

276-
for i, g := range s.compiledRedirects {
323+
for i, r := range s.compiledRedirects {
277324
redir := s.Redirects[i]
278325

279-
// No redirect to self.
280-
if redir.To == pattern {
281-
return Redirect{}
326+
var found bool
327+
328+
if r.from != nil {
329+
if r.from.Match(pattern) {
330+
found = header == nil || r.matchHeader(header)
331+
// We need to do regexp group replacements if needed.
332+
}
282333
}
283334

284-
if g.Match(pattern) {
335+
if r.fromRe != nil {
336+
m := r.fromRe.FindStringSubmatch(pattern)
337+
if m != nil {
338+
if !found {
339+
found = header == nil || r.matchHeader(header)
340+
}
341+
342+
if found {
343+
// Replace $1, $2 etc. in To.
344+
for i, g := range m[1:] {
345+
redir.To = strings.ReplaceAll(redir.To, fmt.Sprintf("$%d", i+1), g)
346+
}
347+
}
348+
}
349+
}
350+
351+
if found {
285352
return redir
286353
}
287354
}
@@ -295,8 +362,22 @@ type Headers struct {
295362
}
296363

297364
type Redirect struct {
365+
// From is the Glob pattern to match.
366+
// One of From or FromRe must be set.
298367
From string
299-
To string
368+
369+
// FromRe is the regexp to match.
370+
// This regexp can contain group matches (e.g. $1) that can be used in the To field.
371+
// One of From or FromRe must be set.
372+
FromRe string
373+
374+
// To is the target URL.
375+
To string
376+
377+
// Headers to match for the redirect.
378+
// This maps the HTTP header name to a Glob pattern with values to match.
379+
// If the map is empty, the redirect will always be triggered.
380+
FromHeaders map[string]string
300381

301382
// HTTP status code to use for the redirect.
302383
// A status code of 200 will trigger a URL rewrite.
@@ -383,25 +464,15 @@ func DecodeServer(cfg Provider) (Server, error) {
383464
_ = mapstructure.WeakDecode(cfg.GetStringMap("server"), s)
384465

385466
for i, redir := range s.Redirects {
386-
// Get it in line with the Hugo server for OK responses.
387-
// We currently treat the 404 as a special case, they are always "ugly", so keep them as is.
388-
if redir.Status != 404 {
389-
redir.To = strings.TrimSuffix(redir.To, "index.html")
390-
if !strings.HasPrefix(redir.To, "https") && !strings.HasSuffix(redir.To, "/") {
391-
// There are some tricky infinite loop situations when dealing
392-
// when the target does not have a trailing slash.
393-
// This can certainly be handled better, but not time for that now.
394-
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)
395-
}
396-
}
467+
redir.To = strings.TrimSuffix(redir.To, "index.html")
397468
s.Redirects[i] = redir
398469
}
399470

400471
if len(s.Redirects) == 0 {
401472
// Set up a default redirect for 404s.
402473
s.Redirects = []Redirect{
403474
{
404-
From: "**",
475+
From: "/**",
405476
To: "/404.html",
406477
Status: 404,
407478
},

0 commit comments

Comments
 (0)