Independent review Not signed off — 1 high (#4)
Address review findings F1 and F2: - F1: check reserved prefixes against the request path before and after cleaning, so /auth/../quests/42 and /api%2F..%2Fquests/42 get the JSON 404 instead of index.html. Add frontend and router tests for both. - F2: add an unhashed asset (robots.txt) to the frontend fixture and assert its status, body and immutable Cache-Control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
3.5 KiB
Go
128 lines
3.5 KiB
Go
// 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, whether before or after the path
|
|
// is cleaned; 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)
|
|
// Check the uncleaned path too, so dot segments cannot escape a reserved
|
|
// prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
|
|
if h.isReserved(r.URL.Path) || 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)
|
|
}
|