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/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) 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) 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, }, }) }