Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle parameter routes in nextjs router #537

Merged
merged 2 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module go.sia.tech/web

go 1.20
go 1.21.7
10 changes: 7 additions & 3 deletions hostd/hostd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import (
"io/fs"
"net/http"

"go.sia.tech/web/ui"
"go.sia.tech/web/internal/nextjs"
)

//go:embed all:assets/*
var assets embed.FS

// Handler returns an http.Handler that serves the hostd UI.
func Handler() http.Handler {
fs, err := fs.Sub(assets, "assets")
assetFS, err := fs.Sub(assets, "assets")
if err != nil {
panic(err)
}
return ui.Handler(fs)
router, err := nextjs.NewRouter(assetFS.(fs.ReadDirFS))
if err != nil {
panic(err)
}
return router
}
189 changes: 189 additions & 0 deletions internal/nextjs/nextjs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package nextjs

import (
"fmt"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"time"
)

type (
// A node is a single node in the router tree.
node struct {
h http.Handler
catchAll bool // true if this node is a catch-all node
optional bool // true if this node is an optional catch-all node
sub map[string]node
}

// A Router is an HTTP request handler built to serve nextjs applications.
Router struct {
fsys fs.FS
root node
}
)

func (r *Router) serveErrorPage(status int, w http.ResponseWriter) {
errorPath := fmt.Sprintf("%d.html", status)

errorPage, err := r.fsys.Open(errorPath)
if err != nil {
http.Error(w, http.StatusText(status), status)
return
}
defer errorPage.Close()

w.WriteHeader(status)
io.Copy(w, errorPage)
}

// ServeHTTP implements the http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
fp := strings.Trim(req.URL.Path, "/")
ext := path.Ext(req.URL.Path)
if ext != "" { // most likely a static file
f, err := r.fsys.Open(fp)
if err == nil {
defer f.Close()
http.ServeContent(w, req, fp, time.Time{}, f.(io.ReadSeeker))
return
}
}

if fp == "" { // root path, serve index.html
r.root.h.ServeHTTP(w, req)
return
}

segments := strings.Split(fp, "/")
node := r.root
for i, segment := range segments {
if child, ok := node.sub[segment]; ok { // check for an exact path match
node = child
} else if child, ok := node.sub["[]"]; ok { // check for a parameter match
node = child
} else {
r.serveErrorPage(http.StatusNotFound, w) // no match found, serve 404
return
}

if node.catchAll {
if i == len(segments)-1 && !node.optional {
// if the catch-all is the last segment and it's not optional, serve 404
r.serveErrorPage(http.StatusNotFound, w)
return
}
node.h.ServeHTTP(w, req)
return
}
}

if node.h == nil { // no handler, serve 404
r.serveErrorPage(http.StatusNotFound, w)
return
}
node.h.ServeHTTP(w, req)
}

func httpFileHandler(fsys fs.FS, path string) http.Handler {
f, err := fsys.Open(path)
if err != nil {
panic(err)
}
f.Close()

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := fsys.Open(path)
if err != nil {
panic(err)
}
defer f.Close()

http.ServeContent(w, r, path, time.Time{}, f.(io.ReadSeeker))
})
}

func traverse(fs fs.ReadDirFS, fp string, segments []string, parent *node) error {
dir, err := fs.ReadDir(fp)
if err != nil {
return fmt.Errorf("failed to read directory %q: %w", strings.Join(segments, "/"), err)
}

for _, child := range dir {
childPath := filepath.Join(fp, child.Name())
name := child.Name()
ext := filepath.Ext(name)
name = strings.TrimSuffix(name, ext)

if !child.IsDir() && ext != ".html" {
continue
}

// add the route to the parent node
switch {
case strings.HasPrefix(name, "[[..."): // optional catch-all, ignore the remaining segments
parent.optional = true
parent.catchAll = true
parent.h = httpFileHandler(fs, childPath)
if len(parent.sub) != 0 {
return fmt.Errorf("failed to add catch-all route %q: parent has children", fp)
}
return nil
case strings.HasPrefix(name, "[..."): // required catch-all, ignore the remaining segments
parent.catchAll = true
parent.h = httpFileHandler(fs, childPath)
if len(parent.sub) != 0 {
return fmt.Errorf("failed to add required catch-all route %q: parent has children", fp)
}
return nil
case strings.HasPrefix(name, "["): // parameterized path
name = "[]"
}

// files may share the same name as a directory, so we need to check if the node already exists
childNode, ok := parent.sub[name]
if !ok {
childNode = node{
sub: make(map[string]node),
}
}

if !child.IsDir() {
if childNode.h != nil {
return fmt.Errorf("failed to add route %q: route already exists", childPath)
}
childNode.h = httpFileHandler(fs, childPath)
}

if child.IsDir() {
if err := traverse(fs, childPath, append(segments, name), &childNode); err != nil {
return err
}
}

parent.sub[name] = childNode
}
return nil
}

// NewRouter creates a new Router instance with the given fs.FS.
func NewRouter(fs fs.ReadDirFS) (*Router, error) {
// the root node serves index.html on /
root := node{
h: httpFileHandler(fs, "index.html"),
sub: make(map[string]node),
}

if err := traverse(fs, ".", nil, &root); err != nil {
return nil, err
}

return &Router{
root: root,
fsys: fs,
}, nil
}
17 changes: 10 additions & 7 deletions ui/ui_test.go → internal/nextjs/nextjs_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ui
package nextjs

import (
"bytes"
Expand Down Expand Up @@ -159,12 +159,13 @@ func TestNextJSRouter(t *testing.T) {
fs.Add("404.html", []byte("404.html"))
fs.Add("foo.html", []byte("foo.html"))
fs.Add("foo/bar.html", []byte("foo/bar.html"))
fs.Add("foo/bar/[bar].html", []byte("foo/bar/[bar].html")) // parameterized path
fs.Add("foo/files/[...key].html", []byte("foo/files/[...key].html")) // required catch-all
fs.Add("foo/objects/[[...key]].html", []byte("foo/objects/[[...key]].html")) // optional catch-all
fs.Add("foo/bar/[bar].html", []byte("foo/bar/[bar].html")) // parameterized path
fs.Add("foo/files/[...key].html", []byte("foo/files/[...key].html")) // required catch-all
fs.Add("foo/objects/[[...key]].html", []byte("foo/objects/[[...key]].html")) // optional catch-all
fs.Add("buckets/[bucket]/files/[[...path]].html", []byte("buckets/[bucket]/files/[[...path]].html")) // param and catch-all
fs.Add("assets/foo.jpg", []byte("assets/foo.jpg"))

router, err := newNextJSRouter(fs)
router, err := NewRouter(fs)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -199,8 +200,10 @@ func TestNextJSRouter(t *testing.T) {
{"/foo/objects/bar", "foo/objects/[[...key]].html", http.StatusOK},
{"/foo/objects/bar/baz", "foo/objects/[[...key]].html", http.StatusOK},
{"/foo/objects/bar/baz/", "foo/objects/[[...key]].html", http.StatusOK},
{"/foo/objects/bar/biz.baz", "foo/objects/[[...key]].html", http.StatusOK}, // with dots in path
{"/foo/biz.baz", "404.html", http.StatusNotFound}, // with dots in path
{"/foo/objects/bar/biz.baz", "foo/objects/[[...key]].html", http.StatusOK}, // with dots in path
{"/foo/biz.baz", "404.html", http.StatusNotFound}, // with dots in path
{"/buckets/default/files/path/to/directory", "buckets/[bucket]/files/[[...path]].html", http.StatusOK}, // param and catch-all
{"/buckets/default/files/path/to/directory/", "buckets/[bucket]/files/[[...path]].html", http.StatusOK},
}

makeRequest := func(path string, status int) ([]byte, error) {
Expand Down
10 changes: 7 additions & 3 deletions renterd/renterd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import (
"io/fs"
"net/http"

"go.sia.tech/web/ui"
"go.sia.tech/web/internal/nextjs"
)

//go:embed all:assets/*
var assets embed.FS

// Handler returns an http.Handler that serves the renterd UI.
func Handler() http.Handler {
fs, err := fs.Sub(assets, "assets")
assetFS, err := fs.Sub(assets, "assets")
if err != nil {
panic(err)
}
return ui.Handler(fs)
router, err := nextjs.NewRouter(assetFS.(fs.ReadDirFS))
if err != nil {
panic(err)
}
return router
}
Loading
Loading