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