Files
bwilliams 9905ffb4cb Served the frontend's build output from server.frontend.dir
internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
2026-10-16 16:00:34 +01:00

127 lines
3.9 KiB
Go

package server
import (
"net/http"
"time"
"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"
)
// routes builds the router. Middleware order (outermost first):
//
// RealIP -> RequestID -> Logger -> Recoverer -> [global rate limit + hooks]
// -> RequestChecks (host, body cap) -> DefaultHeaders
// /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()
router.Use(
middleware.RealIP(middleware.RealIPOptions{
Trusted: s.trusted,
Headers: conf.Proxy.ClientIPHeaders,
StripUntrusted: conf.Proxy.StripUntrusted,
}),
middleware.RequestID,
middleware.Logger(middleware.WithLogger(s.untaggedLogger())),
middleware.Recoverer(middleware.WithLogger(s.untaggedLogger())),
)
router.Use(s.groupStack(GroupGlobal, conf.RateLimits.Global)...)
router.Use(
middleware.RequestChecks(*conf),
middleware.DefaultHeaders(conf.Headers),
)
compress, err := middleware.Compress()
if err != nil {
return nil, err
}
// API
router.Route("/api", func(apiRouter chi.Router) {
apiRouter.Use(middleware.NoStore, compress)
apiRouter.Use(s.groupStack(GroupAPI, conf.RateLimits.API)...)
apiRouter.Use(s.csrf.Protect, middleware.RequireJSON)
apiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteProblem(w, r, http.StatusNotFound, "no such endpoint")
})
apiRouter.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteProblem(w, r, http.StatusMethodNotAllowed, "")
})
apiRouter.Get("/health", s.health)
if s.csrf.TokenEnabled() {
apiRouter.Get("/csrf", s.csrf.IssueHandler)
}
s.api.Routes(apiRouter)
})
// WebSocket (no compression middleware: permessage-deflate is separate)
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
}
// groupStack returns the rate-limit hook (configured or overridden) followed
// by any user-supplied hook middleware for the group.
func (s *Server) groupStack(
group Group, rule config.JSCRateLimitRule,
) []Middleware {
var stack []Middleware
if hook, ok := s.limiters[group]; ok && hook.set {
if hook.limiter != nil {
stack = append(stack, middleware.RateLimit(hook.limiter, hook.key))
}
} else if rule.Enabled && rule.PerSecond > 0 {
limiter := middleware.NewKeyedLimiter(
s.backgroundCtx,
float64(rule.PerSecond),
max(rule.Burst, 1),
s.conf.RateLimits.IdleTTL,
)
stack = append(stack, middleware.RateLimit(limiter, middleware.KeyByIP))
}
return append(stack, s.hooks[group]...)
}
// health serves GET /api/health: the server's status and uptime, the number
// of WebSocket clients, and the client as the server sees it (after proxy
// processing).
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
client := middleware.Client(r)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"uptime": time.Since(s.started).Round(time.Second).String(),
"wsClients": s.hub.Count(),
"client": map[string]any{
"ip": client.IP.String(),
"scheme": client.Scheme,
"viaProxy": client.ViaProxy,
},
})
}