diff --git a/examples/dev/public/404.html b/examples/dev/public/404.html
new file mode 100644
index 0000000..30b67eb
--- /dev/null
+++ b/examples/dev/public/404.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Test
+
+
+ Hello dev 404
+
+
diff --git a/examples/main/public/404.html b/examples/main/public/404.html
new file mode 100644
index 0000000..02bd579
--- /dev/null
+++ b/examples/main/public/404.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Test
+
+
+ Hello world 404
+
+
diff --git a/internal/handler/site/middleware/compression_test.go b/internal/handler/site/middleware/compression_test.go
index 6b06fb6..ca6eb7e 100644
--- a/internal/handler/site/middleware/compression_test.go
+++ b/internal/handler/site/middleware/compression_test.go
@@ -1,22 +1,22 @@
package middleware_test
import (
+ "compress/gzip"
"io"
"net/http"
"net/http/httptest"
"testing"
- "compress/gzip"
+ "github.com/andybalholm/brotli"
"github.com/oursky/pageship/internal/handler/site/middleware"
"github.com/stretchr/testify/assert"
- "github.com/andybalholm/brotli"
)
-type mockHandler struct {
+type compressMockHandler struct {
executeCount int
}
-func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (mh *compressMockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
mh.executeCount++
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("hello"))
@@ -24,7 +24,7 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func TestCacheGzip(t *testing.T) {
//Setup
- h := middleware.Compression(new(mockHandler))
+ h := middleware.Compression(new(compressMockHandler))
//Act Assert
req, err := http.NewRequest("GET", "endpoint", nil)
@@ -47,7 +47,7 @@ func TestCacheGzip(t *testing.T) {
func TestCacheBrotli(t *testing.T) {
//Setup
- h := middleware.Compression(new(mockHandler))
+ h := middleware.Compression(new(compressMockHandler))
//Act Assert
req, err := http.NewRequest("GET", "endpoint", nil)
diff --git a/internal/handler/site/middleware/middleware.go b/internal/handler/site/middleware/middleware.go
index 80e434d..cb62c18 100644
--- a/internal/handler/site/middleware/middleware.go
+++ b/internal/handler/site/middleware/middleware.go
@@ -9,5 +9,6 @@ var Default = []site.Middleware{
CanonicalizePath,
RouteSPA,
IndexPage,
+ NotFound,
compression,
}
diff --git a/internal/handler/site/middleware/notfound.go b/internal/handler/site/middleware/notfound.go
new file mode 100644
index 0000000..390da86
--- /dev/null
+++ b/internal/handler/site/middleware/notfound.go
@@ -0,0 +1,48 @@
+package middleware
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "net/http"
+ "path"
+ "time"
+
+ internalhttputil "github.com/oursky/pageship/internal/httputil"
+ "github.com/oursky/pageship/internal/site"
+)
+
+const NotFoundPage = "404.html"
+
+func NotFound(site *site.Descriptor, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ notFound := false
+
+ for {
+ _, err := site.FS.Stat(r.URL.Path)
+ if errors.Is(err, fs.ErrNotExist) {
+ notFound = true
+ if path.Dir(r.URL.Path) == "/" && path.Base(r.URL.Path) == NotFoundPage {
+ http.NotFound(w, r)
+ return
+ }
+ if path.Base(r.URL.Path) == NotFoundPage {
+ r.URL.Path = path.Join(path.Dir(path.Dir(r.URL.Path)), NotFoundPage)
+ } else {
+ r.URL.Path = path.Join(path.Dir(r.URL.Path), NotFoundPage)
+ }
+ } else {
+ break
+ }
+ }
+
+ if notFound {
+ w.WriteHeader(404)
+ writer := internalhttputil.NewTimeoutResponseWriter(w, 10*time.Second)
+ rsc, _ := site.FS.Open(r.Context(), r.URL.Path)
+ io.Copy(writer, rsc)
+ } else {
+ next.ServeHTTP(w, r)
+ }
+ })
+}
diff --git a/internal/handler/site/middleware/notfound_test.go b/internal/handler/site/middleware/notfound_test.go
new file mode 100644
index 0000000..9d14c00
--- /dev/null
+++ b/internal/handler/site/middleware/notfound_test.go
@@ -0,0 +1,197 @@
+package middleware_test
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "io"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path"
+ "testing"
+ "time"
+
+ "github.com/oursky/pageship/internal/config"
+ "github.com/oursky/pageship/internal/handler/site/middleware"
+ "github.com/oursky/pageship/internal/site"
+ "github.com/stretchr/testify/assert"
+)
+
+type notFoundMockHandler struct {
+ publicFS site.FS
+}
+
+func (mh notFoundMockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ rsc, _ := mh.publicFS.Open(r.Context(), r.URL.Path)
+ http.ServeContent(w, r, path.Base(r.URL.Path), time.Now(), rsc)
+}
+
+type RSCAdapter struct {
+ *bytes.Reader
+}
+
+func (rsca RSCAdapter) Close() error {
+ return nil
+}
+
+type FSAdapter struct {
+ embed.FS
+ subdir string
+}
+
+func (fa FSAdapter) Open(c context.Context, s string) (io.ReadSeekCloser, error) {
+ f, _ := fa.FS.Open(path.Join(fa.subdir, s))
+ b, _ := io.ReadAll(f)
+ return RSCAdapter{bytes.NewReader(b)}, nil
+}
+
+func (fa FSAdapter) Stat(s string) (*site.FileInfo, error) {
+ st, err := fs.Stat(fa.FS, path.Join(fa.subdir, s))
+ if err != nil {
+ return nil, err
+ }
+ return &site.FileInfo{
+ IsDir: st.IsDir(),
+ ModTime: st.ModTime(),
+ Size: st.Size(),
+ ContentType: "",
+ Hash: "",
+ }, nil
+}
+
+var default404 = "404 page not found\n"
+
+func AssertResponse(t *testing.T, h http.Handler, p string, sc int, cont string) {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, &http.Request{
+ URL: &url.URL{
+ Path: p,
+ },
+ })
+ res := rec.Result()
+ assert.Equal(t, sc, res.StatusCode)
+ bo, _ := io.ReadAll(res.Body)
+ assert.Equal(t, cont, string(bo))
+}
+
+//go:embed testdata/testrootwith404andindex
+var testrootwith404andindexFS embed.FS
+
+func TestRootWith404AndIndex(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testrootwith404andindexFS, "testdata/testrootwith404andindex"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testrootwith404andindexFS, "testdata/testrootwith404andindex"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/index.html", 200, "testrootwith404andindex_index")
+ AssertResponse(t, h, "/404.html", 200, "testrootwith404andindex_404")
+ AssertResponse(t, h, "/nonexistant.html", 404, "testrootwith404andindex_404")
+ AssertResponse(t, h, "/nonexistant/index.html", 404, "testrootwith404andindex_404")
+}
+
+//go:embed testdata/testrootwith404
+var testrootwith404FS embed.FS
+
+func TestRootWith404(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testrootwith404FS, "testdata/testrootwith404"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testrootwith404FS, "testdata/testrootwith404"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/index.html", 404, "testrootwith404_404")
+ AssertResponse(t, h, "/404.html", 200, "testrootwith404_404")
+ AssertResponse(t, h, "/nonexistant.html", 404, "testrootwith404_404")
+ AssertResponse(t, h, "/nonexistant/index.html", 404, "testrootwith404_404")
+}
+
+//go:embed testdata/testrootno404
+var testrootno404FS embed.FS
+
+func TestRootno404(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testrootno404FS, "testdata/testrootno404"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testrootno404FS, "testdata/testrootno404"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/index.html", 200, "testrootno404_index")
+ AssertResponse(t, h, "/404.html", 404, default404)
+ AssertResponse(t, h, "/nonexistant.html", 404, default404)
+ AssertResponse(t, h, "/nonexistant/index.html", 404, default404)
+}
+
+//go:embed testdata/testsubdirwith404andindex
+var testsubdirwith404andindexFS embed.FS
+
+func TestSubdirWith404AndIndex(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testsubdirwith404andindexFS, "testdata/testsubdirwith404andindex"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testsubdirwith404andindexFS, "testdata/testsubdirwith404andindex"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/subdir/index.html", 200, "testsubdirwith404andindex_index")
+ AssertResponse(t, h, "404.html", 200, "testsubdirwith404andindex_404")
+ AssertResponse(t, h, "/subdir/nonexistant.html", 404, "testsubdirwith404andindex_404")
+ AssertResponse(t, h, "/subdir/nonexistant/index.html", 404, "testsubdirwith404andindex_404")
+}
+
+//go:embed testdata/testsubdirwith404
+var testsubdirwith404FS embed.FS
+
+func TestSubdirWith404(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testsubdirwith404FS, "testdata/testsubdirwith404"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testsubdirwith404FS, "testdata/testsubdirwith404"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/subdir/index.html", 404, "testsubdirwith404_404")
+ AssertResponse(t, h, "/404.html", 200, "testsubdirwith404_404")
+ AssertResponse(t, h, "/subdir/nonexistant.html", 404, "testsubdirwith404_404")
+ AssertResponse(t, h, "/subdir/nonexistant/index.html", 404, "testsubdirwith404_404")
+}
+
+//go:embed testdata/testsubdirno404
+var testsubdirno404FS embed.FS
+
+func TestSubdirNo404(t *testing.T) {
+ mh := notFoundMockHandler{FSAdapter{testsubdirno404FS, "testdata/testsubdirno404"}}
+ sc := config.DefaultSiteConfig()
+ mockSiteDescriptor := site.Descriptor{
+ ID: "",
+ Domain: "",
+ Config: &sc,
+ FS: FSAdapter{testsubdirno404FS, "testdata/testsubdirno404"},
+ }
+ h := middleware.NotFound(&mockSiteDescriptor, mh)
+
+ AssertResponse(t, h, "/subdir/index.html", 200, "testsubdirno404_index")
+ AssertResponse(t, h, "/subdir/404.html", 404, default404)
+ AssertResponse(t, h, "/subdir/nonexistant.html", 404, default404)
+ AssertResponse(t, h, "/subdir/nonexistant/index.html", 404, default404)
+}
diff --git a/internal/handler/site/middleware/testdata/testrootno404/index.html b/internal/handler/site/middleware/testdata/testrootno404/index.html
new file mode 100644
index 0000000..f89bcda
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testrootno404/index.html
@@ -0,0 +1 @@
+testrootno404_index
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testrootwith404/404.html b/internal/handler/site/middleware/testdata/testrootwith404/404.html
new file mode 100644
index 0000000..d83128f
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testrootwith404/404.html
@@ -0,0 +1 @@
+testrootwith404_404
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testrootwith404andindex/404.html b/internal/handler/site/middleware/testdata/testrootwith404andindex/404.html
new file mode 100644
index 0000000..9c7527b
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testrootwith404andindex/404.html
@@ -0,0 +1 @@
+testrootwith404andindex_404
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testrootwith404andindex/index.html b/internal/handler/site/middleware/testdata/testrootwith404andindex/index.html
new file mode 100644
index 0000000..ca75fff
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testrootwith404andindex/index.html
@@ -0,0 +1 @@
+testrootwith404andindex_index
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testsubdirno404/subdir/index.html b/internal/handler/site/middleware/testdata/testsubdirno404/subdir/index.html
new file mode 100644
index 0000000..0d5732d
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testsubdirno404/subdir/index.html
@@ -0,0 +1 @@
+testsubdirno404_index
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testsubdirwith404/404.html b/internal/handler/site/middleware/testdata/testsubdirwith404/404.html
new file mode 100644
index 0000000..2e60b8b
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testsubdirwith404/404.html
@@ -0,0 +1 @@
+testsubdirwith404_404
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testsubdirwith404andindex/404.html b/internal/handler/site/middleware/testdata/testsubdirwith404andindex/404.html
new file mode 100644
index 0000000..78e68d4
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testsubdirwith404andindex/404.html
@@ -0,0 +1 @@
+testsubdirwith404andindex_404
\ No newline at end of file
diff --git a/internal/handler/site/middleware/testdata/testsubdirwith404andindex/subdir/index.html b/internal/handler/site/middleware/testdata/testsubdirwith404andindex/subdir/index.html
new file mode 100644
index 0000000..48f780a
--- /dev/null
+++ b/internal/handler/site/middleware/testdata/testsubdirwith404andindex/subdir/index.html
@@ -0,0 +1 @@
+testsubdirwith404andindex_index
\ No newline at end of file