Skip to content

Commit f16700a

Browse files
n8maningeralexfreska
authored andcommitted
fix: handle parameter routes in nextjs router
1 parent afc6f04 commit f16700a

File tree

6 files changed

+220
-206
lines changed

6 files changed

+220
-206
lines changed

hostd/hostd.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@ import (
55
"io/fs"
66
"net/http"
77

8-
"go.sia.tech/web/ui"
8+
"go.sia.tech/web/internal/nextjs"
99
)
1010

1111
//go:embed all:assets/*
1212
var assets embed.FS
1313

1414
// Handler returns an http.Handler that serves the hostd UI.
1515
func Handler() http.Handler {
16-
fs, err := fs.Sub(assets, "assets")
16+
assetFS, err := fs.Sub(assets, "assets")
1717
if err != nil {
1818
panic(err)
1919
}
20-
return ui.Handler(fs)
20+
router, err := nextjs.NewRouter(assetFS.(fs.ReadDirFS))
21+
if err != nil {
22+
panic(err)
23+
}
24+
return router
2125
}

internal/nextjs/nextjs.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package nextjs
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"io/fs"
7+
"net/http"
8+
"path"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
)
13+
14+
type (
15+
// A node is a single node in the router tree.
16+
node struct {
17+
h http.Handler
18+
catchAll bool // true if this node is a catch-all node
19+
optional bool // true if this node is an optional catch-all node
20+
sub map[string]node
21+
}
22+
23+
// A Router is an HTTP request handler built to serve nextjs applications.
24+
Router struct {
25+
fsys fs.FS
26+
root node
27+
}
28+
)
29+
30+
func (r *Router) serveErrorPage(status int, w http.ResponseWriter) {
31+
errorPath := fmt.Sprintf("%d.html", status)
32+
33+
errorPage, err := r.fsys.Open(errorPath)
34+
if err != nil {
35+
http.Error(w, http.StatusText(status), status)
36+
return
37+
}
38+
defer errorPage.Close()
39+
40+
w.WriteHeader(status)
41+
io.Copy(w, errorPage)
42+
}
43+
44+
// ServeHTTP implements the http.Handler interface.
45+
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
46+
fp := strings.Trim(req.URL.Path, "/")
47+
ext := path.Ext(req.URL.Path)
48+
if ext != "" { // most likely a static file
49+
f, err := r.fsys.Open(fp)
50+
if err == nil {
51+
defer f.Close()
52+
http.ServeContent(w, req, fp, time.Time{}, f.(io.ReadSeeker))
53+
return
54+
}
55+
}
56+
57+
if fp == "" { // root path, serve index.html
58+
r.root.h.ServeHTTP(w, req)
59+
return
60+
}
61+
62+
segments := strings.Split(fp, "/")
63+
node := r.root
64+
for i, segment := range segments {
65+
if child, ok := node.sub[segment]; ok { // check for an exact path match
66+
node = child
67+
} else if child, ok := node.sub["[]"]; ok { // check for a parameter match
68+
node = child
69+
} else {
70+
r.serveErrorPage(http.StatusNotFound, w) // no match found, serve 404
71+
return
72+
}
73+
74+
if node.catchAll {
75+
if i == len(segments)-1 && !node.optional {
76+
// if the catch-all is the last segment and it's not optional, serve 404
77+
r.serveErrorPage(http.StatusNotFound, w)
78+
return
79+
}
80+
node.h.ServeHTTP(w, req)
81+
return
82+
}
83+
}
84+
85+
if node.h == nil { // no handler, serve 404
86+
r.serveErrorPage(http.StatusNotFound, w)
87+
return
88+
}
89+
node.h.ServeHTTP(w, req)
90+
}
91+
92+
func httpFileHandler(fsys fs.FS, path string) http.Handler {
93+
f, err := fsys.Open(path)
94+
if err != nil {
95+
panic(err)
96+
}
97+
f.Close()
98+
99+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+
f, err := fsys.Open(path)
101+
if err != nil {
102+
panic(err)
103+
}
104+
defer f.Close()
105+
106+
http.ServeContent(w, r, path, time.Time{}, f.(io.ReadSeeker))
107+
})
108+
}
109+
110+
func traverse(fs fs.ReadDirFS, fp string, segments []string, parent *node) error {
111+
dir, err := fs.ReadDir(fp)
112+
if err != nil {
113+
return fmt.Errorf("failed to read directory %q: %w", strings.Join(segments, "/"), err)
114+
}
115+
116+
for _, child := range dir {
117+
childPath := filepath.Join(fp, child.Name())
118+
name := child.Name()
119+
ext := filepath.Ext(name)
120+
name = strings.TrimSuffix(name, ext)
121+
122+
if !child.IsDir() && ext != ".html" {
123+
continue
124+
}
125+
126+
// add the route to the parent node
127+
switch {
128+
case strings.HasPrefix(name, "[[..."): // optional catch-all, ignore the remaining segments
129+
parent.optional = true
130+
parent.catchAll = true
131+
parent.h = httpFileHandler(fs, childPath)
132+
if len(parent.sub) != 0 {
133+
return fmt.Errorf("failed to add catch-all route %q: parent has children", fp)
134+
}
135+
return nil
136+
case strings.HasPrefix(name, "[..."): // required catch-all, ignore the remaining segments
137+
parent.catchAll = true
138+
parent.h = httpFileHandler(fs, childPath)
139+
if len(parent.sub) != 0 {
140+
return fmt.Errorf("failed to add required catch-all route %q: parent has children", fp)
141+
}
142+
return nil
143+
case strings.HasPrefix(name, "["): // parameterized path
144+
name = "[]"
145+
}
146+
147+
// files may share the same name as a directory, so we need to check if the node already exists
148+
childNode, ok := parent.sub[name]
149+
if !ok {
150+
childNode = node{
151+
sub: make(map[string]node),
152+
}
153+
}
154+
155+
if !child.IsDir() {
156+
if childNode.h != nil {
157+
return fmt.Errorf("failed to add route %q: route already exists", childPath)
158+
}
159+
childNode.h = httpFileHandler(fs, childPath)
160+
}
161+
162+
if child.IsDir() {
163+
if err := traverse(fs, childPath, append(segments, name), &childNode); err != nil {
164+
return err
165+
}
166+
}
167+
168+
parent.sub[name] = childNode
169+
}
170+
return nil
171+
}
172+
173+
// NewRouter creates a new Router instance with the given fs.FS.
174+
func NewRouter(fs fs.ReadDirFS) (*Router, error) {
175+
// the root node serves index.html on /
176+
root := node{
177+
h: httpFileHandler(fs, "index.html"),
178+
sub: make(map[string]node),
179+
}
180+
181+
if err := traverse(fs, ".", nil, &root); err != nil {
182+
return nil, err
183+
}
184+
185+
return &Router{
186+
root: root,
187+
fsys: fs,
188+
}, nil
189+
}

ui/ui_test.go renamed to internal/nextjs/nextjs_test.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package ui
1+
package nextjs
22

33
import (
44
"bytes"
@@ -159,12 +159,13 @@ func TestNextJSRouter(t *testing.T) {
159159
fs.Add("404.html", []byte("404.html"))
160160
fs.Add("foo.html", []byte("foo.html"))
161161
fs.Add("foo/bar.html", []byte("foo/bar.html"))
162-
fs.Add("foo/bar/[bar].html", []byte("foo/bar/[bar].html")) // parameterized path
163-
fs.Add("foo/files/[...key].html", []byte("foo/files/[...key].html")) // required catch-all
164-
fs.Add("foo/objects/[[...key]].html", []byte("foo/objects/[[...key]].html")) // optional catch-all
162+
fs.Add("foo/bar/[bar].html", []byte("foo/bar/[bar].html")) // parameterized path
163+
fs.Add("foo/files/[...key].html", []byte("foo/files/[...key].html")) // required catch-all
164+
fs.Add("foo/objects/[[...key]].html", []byte("foo/objects/[[...key]].html")) // optional catch-all
165+
fs.Add("buckets/[bucket]/files/[[...path]].html", []byte("buckets/[bucket]/files/[[...path]].html")) // param and catch-all
165166
fs.Add("assets/foo.jpg", []byte("assets/foo.jpg"))
166167

167-
router, err := newNextJSRouter(fs)
168+
router, err := NewRouter(fs)
168169
if err != nil {
169170
t.Fatal(err)
170171
}
@@ -199,8 +200,10 @@ func TestNextJSRouter(t *testing.T) {
199200
{"/foo/objects/bar", "foo/objects/[[...key]].html", http.StatusOK},
200201
{"/foo/objects/bar/baz", "foo/objects/[[...key]].html", http.StatusOK},
201202
{"/foo/objects/bar/baz/", "foo/objects/[[...key]].html", http.StatusOK},
202-
{"/foo/objects/bar/biz.baz", "foo/objects/[[...key]].html", http.StatusOK}, // with dots in path
203-
{"/foo/biz.baz", "404.html", http.StatusNotFound}, // with dots in path
203+
{"/foo/objects/bar/biz.baz", "foo/objects/[[...key]].html", http.StatusOK}, // with dots in path
204+
{"/foo/biz.baz", "404.html", http.StatusNotFound}, // with dots in path
205+
{"/buckets/default/files/path/to/directory", "buckets/[bucket]/files/[[...path]].html", http.StatusOK}, // param and catch-all
206+
{"/buckets/default/files/path/to/directory/", "buckets/[bucket]/files/[[...path]].html", http.StatusOK},
204207
}
205208

206209
makeRequest := func(path string, status int) ([]byte, error) {

renterd/renterd.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@ import (
55
"io/fs"
66
"net/http"
77

8-
"go.sia.tech/web/ui"
8+
"go.sia.tech/web/internal/nextjs"
99
)
1010

1111
//go:embed all:assets/*
1212
var assets embed.FS
1313

1414
// Handler returns an http.Handler that serves the renterd UI.
1515
func Handler() http.Handler {
16-
fs, err := fs.Sub(assets, "assets")
16+
assetFS, err := fs.Sub(assets, "assets")
1717
if err != nil {
1818
panic(err)
1919
}
20-
return ui.Handler(fs)
20+
router, err := nextjs.NewRouter(assetFS.(fs.ReadDirFS))
21+
if err != nil {
22+
panic(err)
23+
}
24+
return router
2125
}

0 commit comments

Comments
 (0)