diff --git a/.gitignore b/.gitignore index 7523873..56e6e30 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ deploy/secrets/ # Build artifacts and dependencies node_modules dist +# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored. +!internal/frontend/dist/ +internal/frontend/dist/* +!internal/frontend/dist/index.html # Local databases *.db diff --git a/cmd/server/main.go b/cmd/server/main.go index e0d649b..bbf388b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" ) @@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + router, err := server.NewRouter(logger, version, frontend.Assets()) + if err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, - Handler: server.NewRouter(logger, version), + Handler: router, ReadHeaderTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), } diff --git a/internal/frontend/dist/index.html b/internal/frontend/dist/index.html new file mode 100644 index 0000000..9f2b2eb --- /dev/null +++ b/internal/frontend/dist/index.html @@ -0,0 +1,11 @@ + + + + + MealQuest + + + +

MealQuest web assets have not been built.

+ + diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go new file mode 100644 index 0000000..12d1297 --- /dev/null +++ b/internal/frontend/frontend.go @@ -0,0 +1,124 @@ +// Package frontend serves the embedded PWA build with an SPA fallback. +package frontend + +import ( + "embed" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" + "time" +) + +// embedded holds the web build. The build copies web/dist over the committed +// placeholder index.html. +// +//go:embed all:dist +var embedded embed.FS + +const ( + indexFile = "index.html" + + cacheImmutable = "public, max-age=31536000, immutable" + cacheNoCache = "no-cache" +) + +// noCacheFiles must be revalidated on every load: they name the hashed assets +// (or, for the service worker, control their caching), so a stale copy pins an +// old release. +var noCacheFiles = map[string]bool{ + indexFile: true, + "sw.js": true, + "manifest.webmanifest": true, +} + +// Assets returns the embedded web build rooted at its dist directory. +func Assets() fs.FS { + sub, err := fs.Sub(embedded, "dist") + if err != nil { + panic(err) // "dist" is a valid path, so Sub cannot fail + } + return sub +} + +// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a +// path that is not a file in fsys is answered with index.html so client-side +// routes load the app. Paths equal to or under a reserved prefix (for example +// "/api") are never answered with index.html; they are passed to notFound. +// fsys must contain index.html. +func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) { + if _, err := fs.Stat(fsys, indexFile); err != nil { + return nil, fmt.Errorf("frontend assets: %w", err) + } + return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil +} + +type handler struct { + fsys fs.FS + logger *slog.Logger + notFound http.Handler + reserved []string +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + urlPath := path.Clean("/" + r.URL.Path) + if h.isReserved(urlPath) { + h.notFound.ServeHTTP(w, r) + return + } + + name := strings.TrimPrefix(urlPath, "/") + if name == "" || !h.isFile(name) { + name = indexFile + } + h.serveFile(w, r, name) +} + +func (h *handler) isReserved(urlPath string) bool { + for _, prefix := range h.reserved { + if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") { + return true + } + } + return false +} + +func (h *handler) isFile(name string) bool { + info, err := fs.Stat(h.fsys, name) + return err == nil && info.Mode().IsRegular() +} + +func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) { + f, err := h.fsys.Open(name) + if err != nil { + h.fail(w, r, name, err) + return + } + defer f.Close() + content, ok := f.(io.ReadSeeker) + if !ok { + h.fail(w, r, name, errors.New("file does not implement io.Seeker")) + return + } + + if noCacheFiles[name] { + w.Header().Set("Cache-Control", cacheNoCache) + } else { + w.Header().Set("Cache-Control", cacheImmutable) + } + if path.Ext(name) == ".webmanifest" { + w.Header().Set("Content-Type", "application/manifest+json") + } + // Embedded files carry no modification time; ServeContent omits + // Last-Modified for the zero time. + http.ServeContent(w, r, name, time.Time{}, content) +} + +func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) { + h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} diff --git a/internal/frontend/frontend_test.go b/internal/frontend/frontend_test.go new file mode 100644 index 0000000..2564b2d --- /dev/null +++ b/internal/frontend/frontend_test.go @@ -0,0 +1,134 @@ +package frontend + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +const ( + indexBody = "fixture index" + assetBody = "console.log('fixture asset')" + swBody = "self.addEventListener('fetch', () => {})" + manifestBody = `{"name":"MealQuest"}` + notFoundBody = "reserved not found" +) + +func fixture() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte(indexBody)}, + "sw.js": {Data: []byte(swBody)}, + "manifest.webmanifest": {Data: []byte(manifestBody)}, + "assets/index-AbC123.js": {Data: []byte(assetBody)}, + } +} + +func newTestHandler(t *testing.T) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, notFoundBody, http.StatusNotFound) + }) + h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health") + if err != nil { + t.Fatal(err) + } + return h +} + +func TestServe(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + wantBody string + wantCache string + }{ + {"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable}, + {"root", http.MethodGet, "/", indexBody, cacheNoCache}, + {"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache}, + {"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache}, + {"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache}, + {"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache}, + {"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache}, + {"directory", http.MethodGet, "/assets", indexBody, cacheNoCache}, + {"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache}, + {"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache}, + {"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable}, + } + h := newTestHandler(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + if got := rec.Header().Get("Cache-Control"); got != tt.wantCache { + t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache) + } + }) + } +} + +func TestManifestContentType(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)) + + if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" { + t.Errorf("Content-Type = %q, want application/manifest+json", got) + } +} + +func TestReservedPrefixesNotServed(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if got := rec.Body.String(); got != notFoundBody+"\n" { + t.Errorf("body = %q, want the notFound handler's body", got) + } + }) + } +} + +func TestNewHandlerRequiresIndex(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}} + if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil { + t.Fatal("NewHandler without index.html: want error, got nil") + } +} + +func TestAssetsContainsIndex(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil { + t.Fatalf("embedded assets: %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 33b0e38..929c846 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,20 +3,44 @@ package server import ( "encoding/json" + "io/fs" "log/slog" "net/http" "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" ) +// reservedPrefixes are owned by the API router and are never answered with the +// SPA's index.html. +var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"} + // NewRouter returns the HTTP handler for the service. version is reported by -// GET /health. -func NewRouter(logger *slog.Logger, version string) http.Handler { +// GET /health; assets is the web build served for every other GET. +func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) { + notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found") + methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + + web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...) + if err != nil { + return nil, err + } + r := chi.NewRouter() + r.MethodNotAllowed(methodNotAllowed) + r.NotFound(notFound) + r.Get("/health", health(logger, version)) - r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")) - r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")) - return r + r.Route("/api", func(r chi.Router) { + r.MethodNotAllowed(methodNotAllowed) + r.NotFound(notFound) + }) + // Catch-all for the PWA. /auth and /media have no routes yet; the frontend + // handler passes them to notFound rather than the SPA fallback. + r.Method(http.MethodGet, "/*", web) + r.Method(http.MethodHead, "/*", web) + return r, nil } type errorBody struct { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f8e3cc5..400ecc0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,14 +7,35 @@ import ( "maps" "net/http" "net/http/httptest" + "strings" "testing" + "testing/fstest" ) +const ( + fixtureIndex = "fixture index" + fixtureAsset = "console.log('fixture asset')" + notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}` +) + +func newTestRouter(t *testing.T, version string) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + assets := fstest.MapFS{ + "index.html": {Data: []byte(fixtureIndex)}, + "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, + } + r, err := NewRouter(logger, version, assets) + if err != nil { + t.Fatal(err) + } + return r +} + func TestHealth(t *testing.T) { t.Parallel() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(NewRouter(logger, "1.2.3")) + srv := httptest.NewServer(newTestRouter(t, "1.2.3")) t.Cleanup(srv.Close) resp, err := http.Get(srv.URL + "/health") @@ -42,9 +63,8 @@ func TestHealth(t *testing.T) { func TestHealthRejectsOtherMethods(t *testing.T) { t.Parallel() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) rec := httptest.NewRecorder() - NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil)) + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", rec.Code) @@ -68,21 +88,95 @@ func TestHealthRejectsOtherMethods(t *testing.T) { } } -func TestUnknownPathNotFound(t *testing.T) { +func TestReservedPathsNotFound(t *testing.T) { t.Parallel() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - rec := httptest.NewRecorder() - NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil)) + router := newTestRouter(t, "dev") + tests := []struct { + method string + path string + }{ + {http.MethodGet, "/api"}, + {http.MethodGet, "/api/unknown"}, + {http.MethodPost, "/api/unknown"}, + {http.MethodGet, "/auth/callback"}, + {http.MethodGet, "/media/photo.jpg"}, + {http.MethodGet, "/health/deep"}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + t.Parallel() - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", rec.Code) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if got := rec.Body.String(); got != notFoundJSON { + t.Errorf("body = %s, want %s", got, notFoundJSON) + } + }) } - if ct := rec.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Content-Type = %q, want application/json", ct) +} + +func TestFrontend(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + tests := []struct { + path string + wantBody string + wantCache string + }{ + {"/", fixtureIndex, "no-cache"}, + {"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"}, + {"/quests/42", fixtureIndex, "no-cache"}, } - const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}` - if got := rec.Body.String(); got != want { - t.Errorf("body = %s, want %s", got, want) + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + if got := rec.Header().Get("Cache-Control"); got != tt.wantCache { + t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache) + } + }) + } +} + +func TestHealthNotShadowed(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + + if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) { + t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body) + } +} + +func TestFrontendRejectsOtherMethods(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil)) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if strings.Contains(rec.Body.String(), fixtureIndex) { + t.Errorf("body = %q, want no index.html", rec.Body.String()) } }