diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 1f5d7c8..8e1d064 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "log/slog" "os" "os/signal" @@ -16,19 +15,23 @@ import ( "github.com/spf13/cobra" "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/config" "git.avsdev.uk/Go/job-search/internal/events" "git.avsdev.uk/Go/job-search/internal/fetcher" + "git.avsdev.uk/Go/job-search/internal/frontend" "git.avsdev.uk/Go/job-search/internal/logging" "git.avsdev.uk/Go/job-search/internal/server" ) -// NewCommand returns the serve command, which serves the REST API and the -// WebSocket endpoint, and runs a worker for every enabled source. +// NewCommand returns the serve command, which serves the REST API, the +// WebSocket endpoint and the frontend, and runs a worker for every enabled +// source. func NewCommand(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "serve", - Short: "Serve the REST API (/api) and WebSocket (/ws), and fetch from sources", - Long: `Serve the REST API under /api and the WebSocket endpoint under /ws. + Short: "Serve the REST API, WebSocket and frontend, and fetch from sources", + Long: `Serve the REST API under /api, the WebSocket endpoint under /ws, and the +frontend's files from server.frontend.dir under / if it is set. It also runs a worker for every enabled source, as fetcher service does, saving what they fetch to database.file if it is set, and pushing an @@ -82,23 +85,13 @@ set, the API serves the stored opportunities under /api/opportunities.`, conf.Server.AccessLog = accessLogOverride } - var options []server.Option - if conf.Server.AccessLog != "" { - var ( - accessLog *log.Logger - closeAccessLog func() error - ) - accessLog, closeAccessLog, err = logging.OpenAccessLog( - conf.Server.AccessLog, - ) - if err != nil { - return err - } - defer func() { - err = errors.Join(err, closeAccessLog()) - }() - options = append(options, server.WithAccessLog(accessLog)) + options, closeAccessLog, err := serverOptions(&conf.Server) + if err != nil { + return err } + defer func() { + err = errors.Join(err, closeAccessLog()) + }() // ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM. ctx, stop := signal.NotifyContext( @@ -201,3 +194,38 @@ set, the API serves the stored opportunities under /api/opportunities.`, return cmd } + +// serverOptions returns the server's options for conf: the access log, if +// server.access_log is set, and the frontend's files, if server.frontend.dir +// is (checked first). closeAccessLog closes the access log; call it once the +// server has stopped. +func serverOptions( + conf *config.JSCServer, +) ([]server.Option, func() error, error) { + var options []server.Option + closeAccessLog := func() error { + return nil + } + if conf.AccessLog != "" { + accessLog, closeFile, err := logging.OpenAccessLog(conf.AccessLog) + if err != nil { + return nil, nil, err + } + closeAccessLog = closeFile + options = append(options, server.WithAccessLog(accessLog)) + } + + if dir := conf.Frontend.Dir; dir != "" { + files := os.DirFS(dir) + err := frontend.CheckFS(files) + if err != nil { + return nil, nil, errors.Join( + fmt.Errorf("server.frontend.dir %s: %w", dir, err), + closeAccessLog(), + ) + } + options = append(options, server.WithFrontend(files)) + } + + return options, closeAccessLog, nil +} diff --git a/internal/config/README.md b/internal/config/README.md index 0841e0c..310a682 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -90,7 +90,7 @@ The `server` section is split into a number of sub-sections: | `server.csrf` | CSRF and cross-origin protection settings | | `server.tls` | TLS certificate, minimum version and HTTP redirect | | `server.headers` | HTTP header settings including policy headers | -| `server.frontend` | How the frontend is served (not used yet) | +| `server.frontend` | Where the frontend's build output is and how it is cached | | `server.websocket` | Websocket configuration | #### Server @@ -177,13 +177,11 @@ The `server` section is split into a number of sub-sections: #### Frontend -Not used yet. - | Key | Type | Default | Notes | |---|---|---|---| -| `server.frontend.dir` | string | | Serve the frontend from this directory; empty: the embedded build | -| `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files | -| `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: none | +| `server.frontend.dir` | string | | Directory of the frontend's build output, holding `index.html`; empty: no frontend is served (there is no embedded build yet) | +| `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files, cached for a year; empty: `/assets/` | +| `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: `no-cache` | #### Websockets diff --git a/internal/config/conf_server_frontend.go b/internal/config/conf_server_frontend.go index 947613c..99e3d69 100644 --- a/internal/config/conf_server_frontend.go +++ b/internal/config/conf_server_frontend.go @@ -7,11 +7,12 @@ import ( // JSCKServerFrontend is the frontend section's configuration key. const JSCKServerFrontend string = "frontend" -// JSCServerFrontend configures how the server serves the frontend. Nothing -// reads it yet. Dir serves the frontend from disk instead of the embedded -// build; empty means the embedded build. ImmutablePrefix is where Vite emits -// content-hashed files, and IndexCacheControl the Cache-Control of HTML and -// other files without a hash; empty means none. +// JSCServerFrontend configures how the server serves the frontend. Dir is +// the directory of the frontend's build output (holding index.html); empty +// means no frontend is served, as there is no embedded build yet. +// ImmutablePrefix is where Vite emits content-hashed files, cached for a +// year; empty means "/assets/". IndexCacheControl is the Cache-Control of +// HTML and other files without a hash; empty means "no-cache". type JSCServerFrontend struct { Dir string `cfg:"dir"` ImmutablePrefix string `cfg:"immutable_prefix"` diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go new file mode 100644 index 0000000..9cbde60 --- /dev/null +++ b/internal/frontend/frontend.go @@ -0,0 +1,250 @@ +// Package frontend serves the Vite build output (embedded or from disk). +package frontend + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "io/fs" + "mime" + "net/http" + "path" + "strconv" + "strings" + "sync" + "time" +) + +// ErrNoIndex is returned by CheckFS when index.html is missing. +var ErrNoIndex = errors.New("frontend: index.html not found") + +// Options configures the handler. ImmutablePrefix is where Vite emits +// content-hashed files, cached for a year; empty means "/assets/". +// IndexCacheControl is the Cache-Control of HTML and other files without a +// hash; empty means "no-cache". CacheETags caches computed ETags: enable it +// for an embedded build, which never changes, and not for a directory on +// disk, which may change while the server runs. +type Options struct { + ImmutablePrefix string + IndexCacheControl string + CacheETags bool +} + +// Handler serves files from an fs.FS. +type Handler struct { + fsys fs.FS + opts Options + // etags caches ETags by "name|modification time|size" (see etag). + etags sync.Map +} + +// New returns a frontend handler. +func New(fsys fs.FS, opts Options) *Handler { + if opts.ImmutablePrefix == "" { + opts.ImmutablePrefix = "/assets/" + } + if opts.IndexCacheControl == "" { + opts.IndexCacheControl = "no-cache" + } + return &Handler{ + fsys: fsys, + opts: opts, + } +} + +// encodings are the precompressed variants of a file that can be served, in +// order of preference: the Accept-Encoding name and the file extension. +var encodings = []struct { + name, extension string +}{ + {name: "br", extension: ".br"}, + {name: "zstd", extension: ".zst"}, + {name: "gzip", extension: ".gz"}, +} + +// ServeHTTP serves the file named by the request path, or index.html for a +// directory. A missing path without an extension (a client-side route) gets +// index.html; a missing asset gets 404. Only GET and HEAD are allowed. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error( + w, + http.StatusText(http.StatusMethodNotAllowed), + http.StatusMethodNotAllowed, + ) + return + } + + name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if name == "" { + name = "index.html" + } + if !fs.ValidPath(name) { + http.NotFound(w, r) + return + } + + info, err := fs.Stat(h.fsys, name) + if err == nil && info.IsDir() { + name = path.Join(name, "index.html") + info, err = fs.Stat(h.fsys, name) + } + + if err != nil { + // Client-side routes (/settings, /items/42) have no extension: serve + // the SPA shell. Missing assets (/assets/x.js) stay 404 so broken + // deploys are visible instead of returning HTML as JavaScript. + if path.Ext(name) == "" { + name = "index.html" + info, err = fs.Stat(h.fsys, name) + } + if err != nil { + http.NotFound(w, r) + return + } + } + + h.serveFile(w, r, name, info) +} + +// serveFile serves the file name, with info its file info: with its cache +// headers, content type and ETag, and a precompressed variant if the client +// accepts one (see encodings). +func (h *Handler) serveFile( + w http.ResponseWriter, r *http.Request, name string, info fs.FileInfo, +) { + header := w.Header() + + switch { + case strings.HasPrefix("/"+name, h.opts.ImmutablePrefix): + header.Set("Cache-Control", "public, max-age=31536000, immutable") + default: + header.Set("Cache-Control", h.opts.IndexCacheControl) + } + + contentType := mime.TypeByExtension(path.Ext(name)) + if contentType == "" { + contentType = "application/octet-stream" + } + header.Set("Content-Type", contentType) + + // Prefer a precompressed sibling (e.g. from vite-plugin-compression). + served, servedInfo := name, info + acceptEncoding := r.Header.Get("Accept-Encoding") + for _, encoding := range encodings { + if !acceptsEncoding(acceptEncoding, encoding.name) { + continue + } + compressedInfo, err := fs.Stat(h.fsys, name+encoding.extension) + if err == nil && !compressedInfo.IsDir() { + served, servedInfo = name+encoding.extension, compressedInfo + header.Set("Content-Encoding", encoding.name) + break + } + } + if !headerHasToken(header, "Vary", "Accept-Encoding") { + header.Add("Vary", "Accept-Encoding") + } + + file, err := h.fsys.Open(served) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() //nolint:errcheck // read only + + var content io.ReadSeeker + if seeker, ok := file.(io.ReadSeeker); ok { + content = seeker + } else { + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "read error", http.StatusInternalServerError) + return + } + content = bytes.NewReader(data) + } + + if tag, err := h.etag(served, servedInfo, content); err == nil { + header.Set("ETag", tag) + } + // Embedded files have a zero ModTime; ServeContent then omits Last-Modified. + http.ServeContent(w, r, name, servedInfo.ModTime(), content) +} + +// etag returns a strong ETag for content, the file name with info: a hash of +// its content, cached by name, modification time and size if +// Options.CacheETags is set. content is left at its start. +func (h *Handler) etag( + name string, info fs.FileInfo, content io.ReadSeeker, +) (string, error) { + key := name + "|" + info.ModTime().Format(time.RFC3339Nano) + + "|" + strconv.FormatInt(info.Size(), 10) + if h.opts.CacheETags { + if cached, ok := h.etags.Load(key); ok { + return cached.(string), nil + } + } + + sum := sha256.New() + if _, err := io.Copy(sum, content); err != nil { + return "", err + } + if _, err := content.Seek(0, io.SeekStart); err != nil { + return "", err + } + + tag := `"` + base64.RawURLEncoding.EncodeToString(sum.Sum(nil)[:12]) + `"` + if h.opts.CacheETags { + h.etags.Store(key, tag) + } + + return tag, nil +} + +// acceptsEncoding reports whether encoding is acceptable in the +// Accept-Encoding header (present with q > 0). +func acceptsEncoding(header, encoding string) bool { + for _, part := range strings.Split(header, ",") { + token, params, _ := strings.Cut(strings.TrimSpace(part), ";") + if !strings.EqualFold(strings.TrimSpace(token), encoding) { + continue + } + + for _, param := range strings.Split(params, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(param), "=") + if ok && strings.EqualFold(name, "q") { + quality, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + return err == nil && quality > 0 + } + } + + return true + } + + return false +} + +// headerHasToken reports whether the comma-separated header key holds token +// (case insensitively). +func headerHasToken(header http.Header, key, token string) bool { + for _, value := range header.Values(key) { + for _, item := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(item), token) { + return true + } + } + } + return false +} + +// CheckFS verifies the filesystem looks like a Vite build. +func CheckFS(fsys fs.FS) error { + if _, err := fs.Stat(fsys, "index.html"); err != nil { + return ErrNoIndex + } + return nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 8aaa6fe..dd95243 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/frontend" "git.avsdev.uk/Go/job-search/internal/httpx" "git.avsdev.uk/Go/job-search/internal/middleware" ) @@ -18,6 +19,7 @@ import ( // /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF // -> RequireJSON // /ws : [ws rate limit + hooks] -> Origin check (hub) +// /* : Compress -> [frontend hooks] -> static files (if WithFrontend) func (s *Server) routes() (http.Handler, error) { conf := s.conf router := chi.NewRouter() @@ -67,6 +69,20 @@ func (s *Server) routes() (http.Handler, error) { webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit) router.With(webSocketStack...).Get("/ws", s.hub.ServeHTTP) + // Frontend + if s.frontend != nil { + frontendHandler := frontend.New(s.frontend, frontend.Options{ + ImmutablePrefix: conf.Frontend.ImmutablePrefix, + IndexCacheControl: conf.Frontend.IndexCacheControl, + // An embedded build never changes; a directory on disk may. + CacheETags: conf.Frontend.Dir == "", + }) + frontendStack := append( + []Middleware{compress}, s.hooks[GroupFrontend]..., + ) + router.With(frontendStack...).Handle("/*", frontendHandler) + } + return router, nil } diff --git a/internal/server/server.go b/internal/server/server.go index 3eb735b..f364d24 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "errors" "fmt" + "io/fs" "log" "log/slog" "net" @@ -38,6 +39,8 @@ const ( GroupAPI // GroupWebSocket is /ws upgrade requests. GroupWebSocket + // GroupFrontend is the static frontend (see WithFrontend). + GroupFrontend ) // Middleware is the standard net/http middleware shape. @@ -83,6 +86,15 @@ func WithEvents(bus *events.Bus) Option { } } +// WithFrontend makes the server serve the frontend's build output, files, +// for every path outside /api and /ws. Without it there is no frontend: such +// paths get 404. +func WithFrontend(files fs.FS) Option { + return func(s *Server) { + s.frontend = files + } +} + // WithMiddleware appends middleware to a group. This is the generic hook // (auth, metrics, tracing, custom header rules, ...). func WithMiddleware(group Group, middlewares ...Middleware) Option { @@ -120,8 +132,10 @@ type Server struct { log *slog.Logger // accessLog is where the access log is written, or nil for none. accessLog *log.Logger - hooks map[Group][]Middleware - limiters map[Group]limiterHook + // frontend is the frontend's build output, or nil for none. + frontend fs.FS + hooks map[Group][]Middleware + limiters map[Group]limiterHook // trusted are the proxies whose forwarding headers are believed. trusted []netip.Prefix @@ -145,7 +159,7 @@ type Server struct { } // New builds the server and its router. It logs to slog.Default() unless -// given WithLogger. +// given WithLogger, and serves no frontend unless given WithFrontend. func New(conf *config.JSCServer, opts ...Option) (*Server, error) { server := &Server{ conf: conf, diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6b5c25e..d0ad17a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "testing/fstest" "time" "github.com/coder/websocket" @@ -47,7 +48,21 @@ func newTestServer( if mutate != nil { mutate(cfg) } - opts = append([]Option{WithLogger(slog.New(slog.DiscardHandler))}, opts...) + frontendFiles := fstest.MapFS{ + "index.html": { + Data: []byte("t"), + }, + "assets/app-1.js": { + Data: []byte(strings.Repeat("console.log(1);", 200)), + }, + } + opts = append( + []Option{ + WithLogger(slog.New(slog.DiscardHandler)), + WithFrontend(frontendFiles), + }, + opts..., + ) s, err := New(cfg, opts...) if err != nil { t.Fatal(err) @@ -154,6 +169,7 @@ func TestRealIPCoversAllGroups(t *testing.T) { GroupGlobal: "/", GroupAPI: "/api/health", GroupWebSocket: "/ws", + GroupFrontend: "/index.html", } for g, path := range groups { var ran, viaProxy bool @@ -180,6 +196,77 @@ func TestRealIPCoversAllGroups(t *testing.T) { } } +// The frontend: a client-side route gets index.html, uncached; an asset is +// compressed, cached as immutable and revalidated by its ETag; a missing +// asset is 404. +func TestFrontend(t *testing.T) { + ts := newTestServer(t, func(conf *config.JSCServer) { + conf.Headers.ContentSecurityPolicy = "default-src 'self'" + }) + + res, err := http.Get(ts.URL + "/some/client/route") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + cacheControl := res.Header.Get("Cache-Control") + if res.StatusCode != 200 || cacheControl != "no-cache" { + t.Fatalf("spa fallback: %d %q", res.StatusCode, cacheControl) + } + if res.Header.Get("Content-Security-Policy") == "" || + res.Header.Get("X-Request-Id") == "" { + t.Fatal("security headers missing") + } + + req, _ := http.NewRequest("GET", ts.URL+"/assets/app-1.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + res, err = http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + encoding := res.Header.Get("Content-Encoding") + cacheControl = res.Header.Get("Cache-Control") + if encoding != "gzip" || !strings.Contains(cacheControl, "immutable") { + t.Fatalf("asset: encoding=%q cache=%q", encoding, cacheControl) + } + + req.Header.Set("If-None-Match", res.Header.Get("ETag")) + res, _ = http.DefaultTransport.RoundTrip(req) + res.Body.Close() + if res.StatusCode != http.StatusNotModified { + t.Fatalf("etag revalidation: %d", res.StatusCode) + } + + res, _ = http.Get(ts.URL + "/assets/missing.js") + res.Body.Close() + if res.StatusCode != 404 { + t.Fatalf("missing asset: %d", res.StatusCode) + } +} + +// Without WithFrontend, paths outside /api and /ws are 404. +func TestNoFrontend(t *testing.T) { + s, err := New(defaultConf(t), WithLogger(slog.New(slog.DiscardHandler))) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(s.Handler()) + t.Cleanup(func() { + ts.Close() + s.backgroundCancel() + }) + + res, err := http.Get(ts.URL + "/index.html") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusNotFound { + t.Fatalf("no frontend: %d; want 404", res.StatusCode) + } +} + // A WebSocket upgrade is refused from a foreign origin, accepted from an // origin in csrf.cross_origin_protection.trusted_origins, and the client then // gets replies.