Skip to content

Commit 84e715a

Browse files
Merge pull request #485 from ClusterCockpit/change-web-framework
Change web framework
2 parents e681e9e + 396a628 commit 84e715a

File tree

19 files changed

+1081
-245
lines changed

19 files changed

+1081
-245
lines changed

cmd/cc-backend/server.go

Lines changed: 149 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"encoding/json"
1515
"errors"
1616
"fmt"
17-
"io"
1817
"net"
1918
"net/http"
2019
"os"
@@ -36,8 +35,9 @@ import (
3635
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
3736
"github.com/ClusterCockpit/cc-lib/v2/nats"
3837
"github.com/ClusterCockpit/cc-lib/v2/runtime"
39-
"github.com/gorilla/handlers"
40-
"github.com/gorilla/mux"
38+
"github.com/go-chi/chi/v5"
39+
"github.com/go-chi/chi/v5/middleware"
40+
"github.com/go-chi/cors"
4141
httpSwagger "github.com/swaggo/http-swagger"
4242
)
4343

@@ -50,7 +50,7 @@ const (
5050

5151
// Server encapsulates the HTTP server state and dependencies
5252
type Server struct {
53-
router *mux.Router
53+
router chi.Router
5454
server *http.Server
5555
restAPIHandle *api.RestAPI
5656
natsAPIHandle *api.NatsAPI
@@ -70,7 +70,7 @@ func NewServer(version, commit, buildDate string) (*Server, error) {
7070
buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate}
7171

7272
s := &Server{
73-
router: mux.NewRouter(),
73+
router: chi.NewRouter(),
7474
}
7575

7676
if err := s.init(); err != nil {
@@ -106,6 +106,27 @@ func (s *Server) init() error {
106106

107107
authHandle := auth.GetAuthInstance()
108108

109+
// Middleware must be defined before routes in chi
110+
s.router.Use(func(next http.Handler) http.Handler {
111+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
112+
start := time.Now()
113+
ww := middleware.NewWrapResponseWriter(rw, r.ProtoMajor)
114+
next.ServeHTTP(ww, r)
115+
cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
116+
r.Method, r.URL.RequestURI(),
117+
ww.Status(), float32(ww.BytesWritten())/1024,
118+
time.Since(start).Milliseconds())
119+
})
120+
})
121+
s.router.Use(middleware.Compress(5))
122+
s.router.Use(middleware.Recoverer)
123+
s.router.Use(cors.Handler(cors.Options{
124+
AllowCredentials: true,
125+
AllowedHeaders: []string{"X-Requested-With", "Content-Type", "Authorization", "Origin"},
126+
AllowedMethods: []string{"GET", "POST", "HEAD", "OPTIONS"},
127+
AllowedOrigins: []string{"*"},
128+
}))
129+
109130
s.restAPIHandle = api.New()
110131

111132
info := map[string]any{}
@@ -117,11 +138,11 @@ func (s *Server) init() error {
117138
info["hasOpenIDConnect"] = true
118139
}
119140

120-
s.router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
141+
s.router.Get("/login", func(rw http.ResponseWriter, r *http.Request) {
121142
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
122143
cclog.Debugf("##%v##", info)
123144
web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
124-
}).Methods(http.MethodGet)
145+
})
125146
s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
126147
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
127148
web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
@@ -131,13 +152,6 @@ func (s *Server) init() error {
131152
web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
132153
})
133154

134-
secured := s.router.PathPrefix("/").Subrouter()
135-
securedapi := s.router.PathPrefix("/api").Subrouter()
136-
userapi := s.router.PathPrefix("/userapi").Subrouter()
137-
configapi := s.router.PathPrefix("/config").Subrouter()
138-
frontendapi := s.router.PathPrefix("/frontend").Subrouter()
139-
metricstoreapi := s.router.PathPrefix("/api").Subrouter()
140-
141155
if !config.Keys.DisableAuthentication {
142156
// Create login failure handler (used by both /login and /jwt-login)
143157
loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) {
@@ -152,10 +166,10 @@ func (s *Server) init() error {
152166
})
153167
}
154168

155-
s.router.Handle("/login", authHandle.Login(loginFailureHandler)).Methods(http.MethodPost)
156-
s.router.Handle("/jwt-login", authHandle.Login(loginFailureHandler))
169+
s.router.Post("/login", authHandle.Login(loginFailureHandler).ServeHTTP)
170+
s.router.HandleFunc("/jwt-login", authHandle.Login(loginFailureHandler).ServeHTTP)
157171

158-
s.router.Handle("/logout", authHandle.Logout(
172+
s.router.Post("/logout", authHandle.Logout(
159173
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
160174
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
161175
rw.WriteHeader(http.StatusOK)
@@ -166,86 +180,97 @@ func (s *Server) init() error {
166180
Build: buildInfo,
167181
Infos: info,
168182
})
169-
}))).Methods(http.MethodPost)
170-
171-
secured.Use(func(next http.Handler) http.Handler {
172-
return authHandle.Auth(
173-
// On success;
174-
next,
175-
176-
// On failure:
177-
func(rw http.ResponseWriter, r *http.Request, err error) {
178-
rw.WriteHeader(http.StatusUnauthorized)
179-
web.RenderTemplate(rw, "login.tmpl", &web.Page{
180-
Title: "Authentication failed - ClusterCockpit",
181-
MsgType: "alert-danger",
182-
Message: err.Error(),
183-
Build: buildInfo,
184-
Infos: info,
185-
Redirect: r.RequestURI,
183+
})).ServeHTTP)
184+
}
185+
186+
if flagDev {
187+
s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
188+
s.router.Get("/swagger/*", httpSwagger.Handler(
189+
httpSwagger.URL("http://"+config.Keys.Addr+"/swagger/doc.json")))
190+
}
191+
192+
// Secured routes (require authentication)
193+
s.router.Group(func(secured chi.Router) {
194+
if !config.Keys.DisableAuthentication {
195+
secured.Use(func(next http.Handler) http.Handler {
196+
return authHandle.Auth(
197+
next,
198+
func(rw http.ResponseWriter, r *http.Request, err error) {
199+
rw.WriteHeader(http.StatusUnauthorized)
200+
web.RenderTemplate(rw, "login.tmpl", &web.Page{
201+
Title: "Authentication failed - ClusterCockpit",
202+
MsgType: "alert-danger",
203+
Message: err.Error(),
204+
Build: buildInfo,
205+
Infos: info,
206+
Redirect: r.RequestURI,
207+
})
186208
})
187-
})
188-
})
209+
})
210+
}
189211

190-
securedapi.Use(func(next http.Handler) http.Handler {
191-
return authHandle.AuthAPI(
192-
// On success;
193-
next,
194-
// On failure: JSON Response
195-
onFailureResponse)
196-
})
212+
secured.Handle("/query", graphQLServer)
197213

198-
userapi.Use(func(next http.Handler) http.Handler {
199-
return authHandle.AuthUserAPI(
200-
// On success;
201-
next,
202-
// On failure: JSON Response
203-
onFailureResponse)
214+
secured.HandleFunc("/search", func(rw http.ResponseWriter, r *http.Request) {
215+
routerConfig.HandleSearchBar(rw, r, buildInfo)
204216
})
205217

206-
metricstoreapi.Use(func(next http.Handler) http.Handler {
207-
return authHandle.AuthMetricStoreAPI(
208-
// On success;
209-
next,
210-
// On failure: JSON Response
211-
onFailureResponse)
212-
})
218+
routerConfig.SetupRoutes(secured, buildInfo)
219+
})
213220

214-
configapi.Use(func(next http.Handler) http.Handler {
215-
return authHandle.AuthConfigAPI(
216-
// On success;
217-
next,
218-
// On failure: JSON Response
219-
onFailureResponse)
221+
// API routes (JWT token auth)
222+
s.router.Route("/api", func(apiRouter chi.Router) {
223+
// Main API routes with API auth
224+
apiRouter.Group(func(securedapi chi.Router) {
225+
if !config.Keys.DisableAuthentication {
226+
securedapi.Use(func(next http.Handler) http.Handler {
227+
return authHandle.AuthAPI(next, onFailureResponse)
228+
})
229+
}
230+
s.restAPIHandle.MountAPIRoutes(securedapi)
220231
})
221232

222-
frontendapi.Use(func(next http.Handler) http.Handler {
223-
return authHandle.AuthFrontendAPI(
224-
// On success;
225-
next,
226-
// On failure: JSON Response
227-
onFailureResponse)
233+
// Metric store API routes with separate auth
234+
apiRouter.Group(func(metricstoreapi chi.Router) {
235+
if !config.Keys.DisableAuthentication {
236+
metricstoreapi.Use(func(next http.Handler) http.Handler {
237+
return authHandle.AuthMetricStoreAPI(next, onFailureResponse)
238+
})
239+
}
240+
s.restAPIHandle.MountMetricStoreAPIRoutes(metricstoreapi)
228241
})
229-
}
242+
})
230243

231-
if flagDev {
232-
s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
233-
s.router.PathPrefix("/swagger/").Handler(httpSwagger.Handler(
234-
httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet)
235-
}
236-
secured.Handle("/query", graphQLServer)
244+
// User API routes
245+
s.router.Route("/userapi", func(userapi chi.Router) {
246+
if !config.Keys.DisableAuthentication {
247+
userapi.Use(func(next http.Handler) http.Handler {
248+
return authHandle.AuthUserAPI(next, onFailureResponse)
249+
})
250+
}
251+
s.restAPIHandle.MountUserAPIRoutes(userapi)
252+
})
237253

238-
// Send a searchId and then reply with a redirect to a user, or directly send query to job table for jobid and project.
239-
secured.HandleFunc("/search", func(rw http.ResponseWriter, r *http.Request) {
240-
routerConfig.HandleSearchBar(rw, r, buildInfo)
254+
// Config API routes (uses Group with full paths to avoid shadowing
255+
// the /config page route that is registered in the secured group)
256+
s.router.Group(func(configapi chi.Router) {
257+
if !config.Keys.DisableAuthentication {
258+
configapi.Use(func(next http.Handler) http.Handler {
259+
return authHandle.AuthConfigAPI(next, onFailureResponse)
260+
})
261+
}
262+
s.restAPIHandle.MountConfigAPIRoutes(configapi)
241263
})
242264

243-
// Mount all /monitoring/... and /api/... routes.
244-
routerConfig.SetupRoutes(secured, buildInfo)
245-
s.restAPIHandle.MountAPIRoutes(securedapi)
246-
s.restAPIHandle.MountUserAPIRoutes(userapi)
247-
s.restAPIHandle.MountConfigAPIRoutes(configapi)
248-
s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
265+
// Frontend API routes
266+
s.router.Route("/frontend", func(frontendapi chi.Router) {
267+
if !config.Keys.DisableAuthentication {
268+
frontendapi.Use(func(next http.Handler) http.Handler {
269+
return authHandle.AuthFrontendAPI(next, onFailureResponse)
270+
})
271+
}
272+
s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
273+
})
249274

250275
if config.Keys.APISubjects != nil {
251276
s.natsAPIHandle = api.NewNatsAPI()
@@ -254,28 +279,55 @@ func (s *Server) init() error {
254279
}
255280
}
256281

257-
s.restAPIHandle.MountMetricStoreAPIRoutes(metricstoreapi)
282+
// 404 handler for pages and API routes
283+
notFoundHandler := func(rw http.ResponseWriter, r *http.Request) {
284+
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/userapi/") ||
285+
strings.HasPrefix(r.URL.Path, "/frontend/") || strings.HasPrefix(r.URL.Path, "/config/") {
286+
rw.Header().Set("Content-Type", "application/json")
287+
rw.WriteHeader(http.StatusNotFound)
288+
json.NewEncoder(rw).Encode(map[string]string{
289+
"status": "Resource not found",
290+
"error": "the requested endpoint does not exist",
291+
})
292+
return
293+
}
294+
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
295+
rw.WriteHeader(http.StatusNotFound)
296+
web.RenderTemplate(rw, "404.tmpl", &web.Page{
297+
Title: "Page Not Found",
298+
Build: buildInfo,
299+
})
300+
}
258301

259302
if config.Keys.EmbedStaticFiles {
260303
if i, err := os.Stat("./var/img"); err == nil {
261304
if i.IsDir() {
262305
cclog.Info("Use local directory for static images")
263-
s.router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
306+
s.router.Handle("/img/*", http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
264307
}
265308
}
266-
s.router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles()))
309+
fileServer := http.StripPrefix("/", web.ServeFiles())
310+
s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
311+
if web.StaticFileExists(r.URL.Path) {
312+
fileServer.ServeHTTP(rw, r)
313+
return
314+
}
315+
notFoundHandler(rw, r)
316+
}))
267317
} else {
268-
s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles)))
318+
staticDir := http.Dir(config.Keys.StaticFiles)
319+
fileServer := http.FileServer(staticDir)
320+
s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
321+
f, err := staticDir.Open(r.URL.Path)
322+
if err == nil {
323+
f.Close()
324+
fileServer.ServeHTTP(rw, r)
325+
return
326+
}
327+
notFoundHandler(rw, r)
328+
}))
269329
}
270330

271-
s.router.Use(handlers.CompressHandler)
272-
s.router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
273-
s.router.Use(handlers.CORS(
274-
handlers.AllowCredentials(),
275-
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}),
276-
handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}),
277-
handlers.AllowedOrigins([]string{"*"})))
278-
279331
return nil
280332
}
281333

@@ -286,28 +338,14 @@ const (
286338
)
287339

288340
func (s *Server) Start(ctx context.Context) error {
289-
handler := handlers.CustomLoggingHandler(io.Discard, s.router, func(_ io.Writer, params handlers.LogFormatterParams) {
290-
if strings.HasPrefix(params.Request.RequestURI, "/api/") {
291-
cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
292-
params.Request.Method, params.URL.RequestURI(),
293-
params.StatusCode, float32(params.Size)/1024,
294-
time.Since(params.TimeStamp).Milliseconds())
295-
} else {
296-
cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
297-
params.Request.Method, params.URL.RequestURI(),
298-
params.StatusCode, float32(params.Size)/1024,
299-
time.Since(params.TimeStamp).Milliseconds())
300-
}
301-
})
302-
303341
// Use configurable timeouts with defaults
304342
readTimeout := time.Duration(defaultReadTimeout) * time.Second
305343
writeTimeout := time.Duration(defaultWriteTimeout) * time.Second
306344

307345
s.server = &http.Server{
308346
ReadTimeout: readTimeout,
309347
WriteTimeout: writeTimeout,
310-
Handler: handler,
348+
Handler: s.router,
311349
Addr: config.Keys.Addr,
312350
}
313351

0 commit comments

Comments
 (0)