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.
127 lines
3.9 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|