diff --git a/Dockerfile b/Dockerfile index 08eed25..7e0074e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,27 @@ FROM alpine:3.21@sha256:21dc6063fd678b478f57c0e13f47560d0ea4eeba26dfc947b2a4f81f686b9f45 + +# Create non-root user and set up permissions in a single layer RUN adduser -k /dev/null -u 10001 -D gorge \ && chgrp 0 /home/gorge \ - && chmod -R g+rwX /home/gorge -COPY gorge / + && chmod -R g+rwX /home/gorge \ + # Add additional security hardening + && chmod 755 /gorge + +# Copy application binary with explicit permissions +COPY --chmod=755 gorge / + +# Set working directory +WORKDIR /home/gorge + +# Switch to non-root user USER 10001 + +# Define volume VOLUME [ "/home/gorge" ] + +# Set health check +HEALTHCHECK --interval=30s --timeout=3s \ + CMD curl -f http://localhost:8080/readyz || exit 1 + ENTRYPOINT ["/gorge"] CMD [ "serve" ] diff --git a/cmd/config.go b/cmd/config.go deleted file mode 100644 index 844520f..0000000 --- a/cmd/config.go +++ /dev/null @@ -1,3 +0,0 @@ -package cmd - -var apiVersion string diff --git a/cmd/root.go b/cmd/root.go index b394524..211b08c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,7 +17,7 @@ package cmd import ( "fmt" - "os" + "log" "path/filepath" "strings" @@ -47,8 +47,7 @@ var rootCmd = &cobra.Command{ // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) + log.Fatalf("Error executing root command: %v", err) } } @@ -64,11 +63,9 @@ func initConfig(cmd *cobra.Command) error { // Use config file from the flag. v.SetConfigFile(cfgFile) } else { - // Find home directory. home, err := homedir.Dir() if err != nil { - fmt.Println(err) - os.Exit(1) + return fmt.Errorf("failed to get home directory: %w", err) } homeConfig := filepath.Join(home, ".config") @@ -81,27 +78,37 @@ func initConfig(cmd *cobra.Command) error { v.SetEnvPrefix(envPrefix) } - v.AutomaticEnv() // read in environment variables that match + v.AutomaticEnv() // If a config file is found, read it in. - if err := v.ReadInConfig(); err == nil { - fmt.Println("Using config file:", v.ConfigFileUsed()) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + // Only return an error if it's not a missing config file + return fmt.Errorf("failed to read config file: %w", err) + } + } else { + log.Printf("Using config file: %s", v.ConfigFileUsed()) } - bindFlags(cmd, v) - - return nil + return bindFlags(cmd, v) } -func bindFlags(cmd *cobra.Command, v *viper.Viper) { +// bindFlags binds cobra flags with viper config +func bindFlags(cmd *cobra.Command, v *viper.Viper) error { + var bindingErrors []string + cmd.Flags().VisitAll(func(f *pflag.Flag) { - // Determine the naming convention of the flags when represented in the config file configName := f.Name - - // Apply the viper config value to the flag when the flag is not set and viper has a value if !f.Changed && v.IsSet(configName) { val := v.Get(configName) - cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) + if err := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)); err != nil { + bindingErrors = append(bindingErrors, fmt.Sprintf("failed to bind flag %s: %v", f.Name, err)) + } } }) + + if len(bindingErrors) > 0 { + return fmt.Errorf("flag binding errors: %s", strings.Join(bindingErrors, "; ")) + } + return nil } diff --git a/cmd/serve.go b/cmd/serve.go index 84bd90c..f25f668 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -125,12 +125,11 @@ You can also enable the caching functionality to speed things up.`, userService := v3.NewUserOperationsApi() r := chi.NewRouter() - + // 1. Recoverer should be first to catch panics in all other middleware r.Use(middleware.Recoverer) + // 2. RealIP should be early to ensure all other middleware sees the correct IP r.Use(middleware.RealIP) - r.Use(customMiddleware.RequireUserAgent) - x := customMiddleware.NewStatistics() - r.Use(customMiddleware.StatisticsMiddleware(x)) + // 3. CORS should be early as it might reject requests before doing unnecessary work r.Use(cors.Handler(cors.Options{ AllowedOrigins: strings.Split(config.CORSOrigins, ","), AllowedMethods: []string{"GET", "POST", "DELETE", "PATCH"}, @@ -138,14 +137,10 @@ You can also enable the caching functionality to speed things up.`, AllowCredentials: false, MaxAge: 300, })) - if !config.NoCache { - customKeyFunc := func(r *http.Request) uint64 { - token := r.Header.Get("Authorization") - return stampede.StringToHash(r.Method, strings.ToLower(token)) - } - cachedMiddleware := stampede.HandlerWithKey(512, time.Duration(config.CacheMaxAge)*time.Second, customKeyFunc, strings.Split(config.CachePrefixes, ",")...) - r.Use(cachedMiddleware) - } + // 4. RequireUserAgent should be early to ensure all other middleware sees the correct user agent + r.Use(customMiddleware.RequireUserAgent) + + x := customMiddleware.NewStatistics() if config.UI { r.Group(func(r chi.Router) { @@ -160,6 +155,55 @@ You can also enable the caching functionality to speed things up.`, } r.Group(func(r chi.Router) { + if !config.NoCache { + log.Log.Debug("Setting up cache middleware") + customKeyFunc := func(r *http.Request) uint64 { + token := r.Header.Get("Authorization") + return stampede.StringToHash(r.Method, r.URL.Path, strings.ToLower(token)) + } + + cachedMiddleware := stampede.HandlerWithKey( + 512, + time.Duration(config.CacheMaxAge)*time.Second, + customKeyFunc, + ) + log.Log.Debugf("Cache middleware configured with prefixes: %s", config.CachePrefixes) + + cachePrefixes := strings.Split(config.CachePrefixes, ",") + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + shouldCache := false + for _, prefix := range cachePrefixes { + if strings.HasPrefix(r.URL.Path, strings.TrimSpace(prefix)) { + shouldCache = true + break + } + } + + if shouldCache { + wrapper := customMiddleware.NewResponseWrapper(w) + // Set default cache status before serving + // w.Header().Set("X-Cache", "MISS from gorge") + + cachedMiddleware(next).ServeHTTP(wrapper, r) + + // Only override if it was served from cache + // TODO: this is not working as expected + if wrapper.WasWritten() { + log.Log.Debugf("Serving cached response for path: %s", r.URL.Path) + w.Header().Set("X-Cache", "HIT from gorge") + } else { + log.Log.Debugf("Cache miss for path: %s", r.URL.Path) + w.Header().Set("X-Cache", "MISS from gorge") + } + } else { + next.ServeHTTP(w, r) + } + }) + }) + } + if config.FallbackProxyUrl != "" { proxies := strings.Split(config.FallbackProxyUrl, ",") slices.Reverse(proxies) @@ -190,6 +234,10 @@ You can also enable the caching functionality to speed things up.`, )) } } + + // StatisticsMiddleware should be last to ensure all other middleware is counted + r.Use(customMiddleware.StatisticsMiddleware(x)) + apiRouter := openapi.NewRouter( openapi.NewModuleOperationsAPIController(moduleService), openapi.NewReleaseOperationsAPIController(releaseService), @@ -212,34 +260,35 @@ You can also enable the caching functionality to speed things up.`, w.Write([]byte(`{"message": "ok"}`)) }) - ctx, restoreDefaultSignalHandling := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Create signal handling context + sigCtx, restoreDefaultSignalHandling := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer restoreDefaultSignalHandling() - g, gCtx := errgroup.WithContext(ctx) + g, gCtx := errgroup.WithContext(sigCtx) + + if err := backend.ConfiguredBackend.LoadModules(); err != nil { + log.Log.Fatal(fmt.Errorf("initial module load failed: %w", err)) + } - // if set, continuously check modules directory every ModulesScanSec seconds - // otherwise, check only at startup if config.ModulesScanSec > 0 { g.Go(func() error { - // Call LoadModules immediately on startup - if err := backend.ConfiguredBackend.LoadModules(); err != nil { - return err - } + ticker := time.NewTicker(time.Duration(config.ModulesScanSec) * time.Second) + defer ticker.Stop() + for { select { case <-gCtx.Done(): - log.Log.Debugln("Canceling module scan goroutine") return nil - case <-time.After(time.Duration(config.ModulesScanSec) * time.Second): + case <-ticker.C: if err := backend.ConfiguredBackend.LoadModules(); err != nil { - return err + log.Log.Errorf("Failed to load modules: %v", err) + // Continue running instead of failing completely } } } }) - } else { - if err := backend.ConfiguredBackend.LoadModules(); err != nil { - log.Log.Panic(err) - } } bindPort := fmt.Sprintf("%s:%d", config.Bind, config.Port) @@ -253,20 +302,14 @@ You can also enable the caching functionality to speed things up.`, wantTLS := config.TlsKeyPath != "" && config.TlsCertPath != "" if wantTLS { - certificate, err := os.ReadFile(config.TlsCertPath) + cert, err := tls.LoadX509KeyPair(config.TlsCertPath, config.TlsKeyPath) if err != nil { - log.Log.Fatal(err) - } - key, err := os.ReadFile(config.TlsKeyPath) - if err != nil { - log.Log.Fatal(err) - } - cert, err := tls.X509KeyPair(certificate, key) - if err != nil { - log.Log.Fatal(err) + log.Log.Fatalf("Failed to load TLS certificates: %v", err) } + tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, } server.TLSConfig = tlsConfig } @@ -293,12 +336,14 @@ You can also enable the caching functionality to speed things up.`, g.Go(func() error { <-gCtx.Done() - - log.Log.Debugln("Shutting down server (timeout: 5s)") - gracefullCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second) defer cancelShutdown() - return server.Shutdown(gracefullCtx) + log.Log.Info("Shutting down server...") + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("server shutdown failed: %w", err) + } + return nil }) if err := g.Wait(); err != nil { @@ -335,17 +380,3 @@ func init() { serveCmd.Flags().BoolVar(&config.NoCache, "no-cache", false, "disables the caching functionality") serveCmd.Flags().BoolVar(&config.ImportProxiedReleases, "import-proxied-releases", false, "add every proxied modules to local store") } - -func checkModules(sleepSeconds int) { - for { - err := backend.ConfiguredBackend.LoadModules() - if err != nil { - log.Log.Fatal(err) - } - if sleepSeconds > 0 { - time.Sleep(time.Duration(sleepSeconds) * time.Second) - } else { - break - } - } -} diff --git a/go.mod b/go.mod index 47a9ca0..8ffef27 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect diff --git a/go.sum b/go.sum index aa5f244..c6e65a8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= -github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -15,14 +13,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A= -github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80= github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs= github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= github.com/go-chi/stampede v0.6.0 h1:9YXCHtnePdj02neMOHysC93WAi3ZXZA8SygCmooNE6o= @@ -53,8 +47,6 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E= -github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0= github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= @@ -99,8 +91,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -109,22 +102,14 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/middleware/proxy.go b/internal/middleware/proxy.go index 1128922..158aa51 100644 --- a/internal/middleware/proxy.go +++ b/internal/middleware/proxy.go @@ -49,6 +49,13 @@ func NewSingleHostReverseProxy(target *url.URL) *httputil.ReverseProxy { func ProxyFallback(upstreamHost string, forwardToProxy func(int) bool, proxiedResponseCb func(*http.Response)) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Store original headers before any modifications + originalHeaders := make(http.Header) + for k, v := range w.Header() { + originalHeaders[k] = v + } + capturedResponseWriter := NewCapturedResponseWriter(w) next.ServeHTTP(capturedResponseWriter, r) @@ -57,6 +64,10 @@ func ProxyFallback(upstreamHost string, forwardToProxy func(int) bool, proxiedRe u, err := url.Parse(upstreamHost) if err != nil { log.Log.Error(err) + // Restore original headers before sending captured response + for k, v := range originalHeaders { + w.Header()[k] = v + } capturedResponseWriter.sendCapturedResponse() return } @@ -75,6 +86,10 @@ func ProxyFallback(upstreamHost string, forwardToProxy func(int) bool, proxiedRe // if some error occurs, return the original content proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { log.Log.Error(err) + // Restore original headers before sending captured response + for k, v := range originalHeaders { + w.Header()[k] = v + } capturedResponseWriter.sendCapturedResponse() } @@ -83,6 +98,10 @@ func ProxyFallback(upstreamHost string, forwardToProxy func(int) bool, proxiedRe } // If the response status is not 404, serve the original response + // Restore original headers before sending captured response + for k, v := range originalHeaders { + w.Header()[k] = v + } capturedResponseWriter.sendCapturedResponse() }) } diff --git a/internal/middleware/response_wrapper.go b/internal/middleware/response_wrapper.go new file mode 100644 index 0000000..a80e473 --- /dev/null +++ b/internal/middleware/response_wrapper.go @@ -0,0 +1,26 @@ +package middleware + +import "net/http" + +type ResponseWrapper struct { + http.ResponseWriter + written bool +} + +func NewResponseWrapper(w http.ResponseWriter) *ResponseWrapper { + return &ResponseWrapper{ResponseWriter: w} +} + +func (w *ResponseWrapper) Write(b []byte) (int, error) { + w.written = true + return w.ResponseWriter.Write(b) +} + +func (w *ResponseWrapper) WriteHeader(statusCode int) { + w.written = true + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *ResponseWrapper) WasWritten() bool { + return w.written +} diff --git a/internal/middleware/stats.go b/internal/middleware/stats.go index 456376e..a0cdcd6 100644 --- a/internal/middleware/stats.go +++ b/internal/middleware/stats.go @@ -10,8 +10,12 @@ type Statistics struct { ActiveConnections int TotalConnections int TotalResponseTime time.Duration + TotalCacheHits int + TotalCacheMisses int ConnectionsPerEndpoint map[string]int ResponseTimePerEndpoint map[string]time.Duration + CacheHitsPerEndpoint map[string]int + CacheMissesPerEndpoint map[string]int Mutex sync.Mutex } @@ -20,7 +24,11 @@ func NewStatistics() *Statistics { ActiveConnections: 0, TotalConnections: 0, TotalResponseTime: 0, + TotalCacheHits: 0, + TotalCacheMisses: 0, ConnectionsPerEndpoint: make(map[string]int), + CacheHitsPerEndpoint: make(map[string]int), + CacheMissesPerEndpoint: make(map[string]int), ResponseTimePerEndpoint: make(map[string]time.Duration), } } @@ -33,12 +41,22 @@ func StatisticsMiddleware(stats *Statistics) func(next http.Handler) http.Handle stats.ActiveConnections++ stats.TotalConnections++ stats.ConnectionsPerEndpoint[r.URL.Path]++ + stats.Mutex.Unlock() defer func() { duration := time.Since(start) stats.Mutex.Lock() stats.ActiveConnections-- + + if w.Header().Get("X-Cache") == "HIT from gorge" { + stats.TotalCacheHits++ + stats.CacheHitsPerEndpoint[r.URL.Path]++ + } else { + stats.TotalCacheMisses++ + stats.CacheMissesPerEndpoint[r.URL.Path]++ + } + stats.TotalResponseTime += duration stats.ResponseTimePerEndpoint[r.URL.Path] += duration stats.Mutex.Unlock() diff --git a/internal/v3/api/module.go b/internal/v3/api/module.go index 49dae23..a981fac 100644 --- a/internal/v3/api/module.go +++ b/internal/v3/api/module.go @@ -8,12 +8,19 @@ import ( "slices" "strconv" "strings" + "time" "github.com/dadav/gorge/internal/v3/backend" "github.com/dadav/gorge/internal/v3/utils" gen "github.com/dadav/gorge/pkg/gen/v3/openapi" ) +const ( + defaultLimit = 20 + maxLimit = 100 + defaultOffset = 0 +) + type ModuleOperationsApi struct { gen.ModuleOperationsAPIServicer } @@ -56,25 +63,54 @@ func (s *ModuleOperationsApi) DeleteModule(ctx context.Context, moduleSlug strin // DeprecateModule - Deprecate module func (s *ModuleOperationsApi) DeprecateModule(ctx context.Context, moduleSlug string, deprecationRequest gen.DeprecationRequest) (gen.ImplResponse, error) { - // TODO - update DeprecateModule with the required logic for this service method. - // Add api_module_operations_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(204, {}) or use other options such as http.Ok ... - // return Response(204, nil),nil + // Validate module slug + if !utils.CheckModuleSlug(moduleSlug) { + err := errors.New("invalid module slug") + return gen.Response( + http.StatusBadRequest, + DeleteModule500Response{ + Message: err.Error(), + Errors: []string{err.Error()}, + }, + ), nil + } - // TODO: Uncomment the next line to return response Response(400, GetFile400Response{}) or use other options such as http.Ok ... - // return Response(400, GetFile400Response{}), nil + // Check if module exists + module, err := backend.ConfiguredBackend.GetModuleBySlug(moduleSlug) + if err != nil { + return gen.Response( + http.StatusNotFound, + GetModule404Response{ + Message: "Module not found", + Errors: []string{"Module could not be found"}, + }, + ), nil + } - // TODO: Uncomment the next line to return response Response(401, GetUserSearchFilters401Response{}) or use other options such as http.Ok ... - // return Response(401, GetUserSearchFilters401Response{}), nil + // Update module deprecation status + deprecatedAt := time.Now().UTC().Format(time.RFC3339) + module.DeprecatedAt = &deprecatedAt + module.DeprecatedFor = deprecationRequest.Params.Reason - // TODO: Uncomment the next line to return response Response(403, DeleteUserSearchFilter403Response{}) or use other options such as http.Ok ... - // return Response(403, DeleteUserSearchFilter403Response{}), nil + if *deprecationRequest.Params.ReplacementSlug != "" { + module.SupersededBy = gen.ModuleSupersededBy{ + Slug: *deprecationRequest.Params.ReplacementSlug, + } + } - // TODO: Uncomment the next line to return response Response(404, GetFile404Response{}) or use other options such as http.Ok ... - // return Response(404, GetFile404Response{}), nil + // Save the updated module + err = backend.ConfiguredBackend.UpdateModule(module) + if err != nil { + return gen.Response( + http.StatusInternalServerError, + DeleteModule500Response{ + Message: "Failed to deprecate module", + Errors: []string{err.Error()}, + }, + ), nil + } - return gen.Response(http.StatusNotImplemented, nil), errors.New("DeprecateModule method not implemented") + return gen.Response(http.StatusNoContent, nil), nil } type GetModule404Response struct { @@ -104,91 +140,115 @@ func (s *ModuleOperationsApi) GetModule(ctx context.Context, moduleSlug string, // GetModules - List modules func (s *ModuleOperationsApi) GetModules(ctx context.Context, limit int32, offset int32, sortBy string, query string, tag string, owner string, withTasks bool, withPlans bool, withPdk bool, premium bool, excludePremium bool, endorsements []string, operatingsystem string, operatingsystemrelease string, peRequirement string, puppetRequirement string, withMinimumScore int32, moduleGroups []string, showDeleted bool, hideDeprecated bool, onlyLatest bool, slugs []string, withHtml bool, includeFields []string, excludeFields []string, ifModifiedSince string, startsWith string, supported bool, withReleaseSince string) (gen.ImplResponse, error) { - results := []gen.Module{} - filtered := []gen.Module{} - allModules, _ := backend.ConfiguredBackend.GetAllModules() - params := url.Values{} - params.Add("offset", strconv.Itoa(int(offset))) - params.Add("limit", strconv.Itoa(int(limit))) + // Validate and set defaults for limit/offset + if limit <= 0 { + limit = defaultLimit + } else if limit > maxLimit { + limit = maxLimit + } + if offset < 0 { + offset = defaultOffset + } - if int(offset)+1 > len(allModules) { - return gen.Response(404, GetModule404Response{ - Message: "Invalid offset", - Errors: []string{"The given offset is larger than the total number of (filtered) modules."}, - }), nil + // Get all modules with error handling + allModules, err := backend.ConfiguredBackend.GetAllModules() + if err != nil { + return gen.Response( + http.StatusInternalServerError, + GetModule500Response{ + Message: "Failed to fetch modules", + Errors: []string{err.Error()}, + }), nil } - for _, m := range allModules[offset:] { - var filterMatched, filterSet bool - if query != "" { - filterSet = true - filterMatched = strings.Contains(m.Slug, query) || strings.Contains(m.Owner.Slug, query) - params.Add("query", query) - } + // Check offset validity early + if int(offset) >= len(allModules) { + return gen.Response( + http.StatusNotFound, + GetModule404Response{ + Message: "Invalid offset", + Errors: []string{"The given offset is larger than the total number of modules"}, + }), nil + } - if tag != "" { - filterSet = true - filterMatched = slices.Contains(m.CurrentRelease.Tags, tag) - params.Add("tag", tag) - } + // Create filter function type and map of filters + type filterFunc func(m *gen.Module) bool + filters := make(map[string]filterFunc) - if owner != "" { - filterSet = true - filterMatched = m.Owner.Username == owner - params.Add("owner", owner) + // Add filters conditionally + if query != "" { + filters["query"] = func(m *gen.Module) bool { + return strings.Contains(m.Slug, query) || strings.Contains(m.Owner.Slug, query) } - - if withTasks { - filterSet = true - filterMatched = len(m.CurrentRelease.Tasks) > 0 - params.Add("with_tasks", strconv.FormatBool(withTasks)) + } + if tag != "" { + filters["tag"] = func(m *gen.Module) bool { + return slices.Contains(m.CurrentRelease.Tags, tag) } - - if withPlans { - filterSet = true - filterMatched = len(m.CurrentRelease.Plans) > 0 - params.Add("with_plans", strconv.FormatBool(withPlans)) + } + if owner != "" { + filters["owner"] = func(m *gen.Module) bool { + return m.Owner.Username == owner } - - if withPdk { - filterSet = true - filterMatched = m.CurrentRelease.Pdk - params.Add("with_pdk", strconv.FormatBool(withPdk)) + } + if withTasks { + filters["with_tasks"] = func(m *gen.Module) bool { + return len(m.CurrentRelease.Tasks) > 0 } - - if premium { - filterSet = true - filterMatched = m.Premium - params.Add("premium", strconv.FormatBool(premium)) + } + if withPlans { + filters["with_plans"] = func(m *gen.Module) bool { + return len(m.CurrentRelease.Plans) > 0 } - - if excludePremium { - filterSet = true - filterMatched = !m.Premium - params.Add("exclude_premium", strconv.FormatBool(excludePremium)) + } + if withPdk { + filters["with_pdk"] = func(m *gen.Module) bool { + return m.CurrentRelease.Pdk } - - if len(endorsements) > 0 { - filterSet = true - filterMatched = m.Endorsement != nil && slices.Contains(endorsements, *m.Endorsement) - params.Add("endorsements", "["+strings.Join(endorsements, ",")+"]") + } + if premium { + filters["premium"] = func(m *gen.Module) bool { + return m.Premium + } + } + if excludePremium { + filters["exclude_premium"] = func(m *gen.Module) bool { + return !m.Premium + } + } + if len(endorsements) > 0 { + filters["endorsements"] = func(m *gen.Module) bool { + return m.Endorsement != nil && slices.Contains(endorsements, *m.Endorsement) } + } - if !filterSet || filterMatched { + // Apply filters + filtered := make([]gen.Module, 0) + for _, m := range allModules[offset:] { + passes := true + for _, filter := range filters { + if !filter(m) { + passes = false + break + } + } + if passes { filtered = append(filtered, *m) } } - i := 1 - for _, module := range filtered { - if i > int(limit) { - break - } - results = append(results, module) - i++ + // Apply pagination + endIndex := int(limit) + if endIndex > len(filtered) { + endIndex = len(filtered) } + results := filtered[:endIndex] base, _ := url.Parse("/v3/modules") + params := url.Values{} + params.Add("offset", strconv.Itoa(int(offset))) + params.Add("limit", strconv.Itoa(int(limit))) + base.RawQuery = params.Encode() currentInf := interface{}(base.String()) params.Set("offset", "0") diff --git a/internal/v3/api/release.go b/internal/v3/api/release.go index 05cd4ee..88a0236 100644 --- a/internal/v3/api/release.go +++ b/internal/v3/api/release.go @@ -32,14 +32,26 @@ type GetRelease404Response struct { Errors []string `json:"errors,omitempty"` } +// Add custom error types for better error handling +type ReleaseError struct { + Code int + Message string + Errors []string +} + // AddRelease - Create module release func (s *ReleaseOperationsApi) AddRelease(ctx context.Context, addReleaseRequest gen.AddReleaseRequest) (gen.ImplResponse, error) { - base64EncodedTarball := addReleaseRequest.File + if addReleaseRequest.File == "" { + return gen.Response(400, gen.GetFile400Response{ + Message: "No file data provided", + Errors: []string{"file data is required"}, + }), nil + } - decodedTarball, err := base64.StdEncoding.DecodeString(base64EncodedTarball) + decodedTarball, err := base64.StdEncoding.DecodeString(addReleaseRequest.File) if err != nil { return gen.Response(400, gen.GetFile400Response{ - Message: "Could not decode provided data", + Message: "Invalid base64 encoded data", Errors: []string{err.Error()}, }), nil } @@ -47,7 +59,7 @@ func (s *ReleaseOperationsApi) AddRelease(ctx context.Context, addReleaseRequest release, err := backend.ConfiguredBackend.AddRelease(decodedTarball) if err != nil { return gen.Response(400, gen.GetFile400Response{ - Message: "could not add release", + Message: "Failed to add release", Errors: []string{err.Error()}, }), nil } @@ -102,21 +114,44 @@ type GetFile400Response struct { // GetFile - Download module release func (s *ReleaseOperationsApi) GetFile(ctx context.Context, filename string) (gen.ImplResponse, error) { - if !utils.CheckReleaseSlug(strings.TrimSuffix(filename, ".tar.gz")) { + + if filename == "" { return gen.Response(400, gen.GetFile400Response{ - Message: http.StatusText(http.StatusNotFound), + Message: "No filename provided", + Errors: []string{"filename is required"}, + }), nil + } + + // Validate the filename to ensure it does not contain any path separators or parent directory references + if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") { + return gen.Response(400, gen.GetFile400Response{ + Message: "Invalid filename", + Errors: []string{"filename contains invalid characters"}, + }), nil + } + + releaseSlug := strings.TrimSuffix(filename, ".tar.gz") + if !utils.CheckReleaseSlug(releaseSlug) { + return gen.Response(400, gen.GetFile400Response{ + Message: "Invalid release slug format", Errors: []string{"release slug is invalid"}, }), nil } - f, err := os.Open(filepath.Join(config.ModulesDir, ReleaseToModule(filename), filename)) + filePath := filepath.Join(config.ModulesDir, ReleaseToModule(filename), filename) + + f, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { return gen.Response(http.StatusNotFound, gen.GetFile404Response{ - Message: http.StatusText(http.StatusNotFound), + Message: "File not found", Errors: []string{"the file does not exist"}, }), nil } + return gen.Response(http.StatusInternalServerError, GetRelease500Response{ + Message: "Failed to open file", + Errors: []string{err.Error()}, + }), nil } return gen.Response(http.StatusOK, f), nil @@ -218,6 +253,13 @@ func (s *ReleaseOperationsApi) GetReleasePlans(ctx context.Context, releaseSlug // GetReleases - List module releases func (s *ReleaseOperationsApi) GetReleases(ctx context.Context, limit int32, offset int32, sortBy string, module string, owner string, withPdk bool, operatingsystem string, operatingsystemrelease string, peRequirement string, puppetRequirement string, moduleGroups []string, showDeleted bool, hideDeprecated bool, withHtml bool, includeFields []string, excludeFields []string, ifModifiedSince string, supported bool) (gen.ImplResponse, error) { + if limit <= 0 { + limit = 20 // Default limit + } + if offset < 0 { + offset = 0 + } + results := []gen.Release{} filtered := []gen.Release{} allReleases, _ := backend.ConfiguredBackend.GetAllReleases() diff --git a/internal/v3/backend/filesystem.go b/internal/v3/backend/filesystem.go index c7e1488..b914f00 100644 --- a/internal/v3/backend/filesystem.go +++ b/internal/v3/backend/filesystem.go @@ -34,6 +34,13 @@ type FilesystemBackend struct { var _ Backend = (*FilesystemBackend)(nil) +const ( + defaultVersion = "0.0.0" + metadataFile = "metadata.json" + readmeFile = "README.md" + tarGzExt = ".tar.gz" +) + func NewFilesystemBackend(path string) *FilesystemBackend { return &FilesystemBackend{ Modules: map[string]*gen.Module{}, @@ -43,15 +50,24 @@ func NewFilesystemBackend(path string) *FilesystemBackend { } func findLatestVersion(releases []gen.ReleaseAbbreviated) string { - latest := "0.0.0" - for i, r := range releases { - if i == 0 { - latest = r.Version + if len(releases) == 0 { + return defaultVersion + } + + latest := releases[0].Version + for _, r := range releases[1:] { + vVersion, err := version.NewVersion(r.Version) + if err != nil { + log.Log.Warnf("invalid version: %s", r.Version) + continue + } + + vlatest, err := version.NewVersion(latest) + if err != nil { + log.Log.Warnf("invalid version: %s", latest) continue } - vVersion, _ := version.NewVersion(r.Version) - vlatest, _ := version.NewVersion(latest) if vVersion.Compare(vlatest) >= 1 { latest = r.Version } @@ -412,16 +428,20 @@ func ReadReleaseMetadataFromFile(path string) (*model.ReleaseMetadata, string, e } func ReadReleaseMetadataFromBytes(data []byte) (*model.ReleaseMetadata, string, error) { + if len(data) == 0 { + return nil, "", errors.New("empty data provided") + } + var jsonData bytes.Buffer var releaseMetadata model.ReleaseMetadata readme := new(strings.Builder) f := bytes.NewReader(data) - g, err := gzip.NewReader(f) if err != nil { - return nil, readme.String(), err + return nil, "", fmt.Errorf("failed to create gzip reader: %v", err) } + defer g.Close() tarReader := tar.NewReader(g) @@ -464,3 +484,15 @@ func ReadReleaseMetadataFromBytes(data []byte) (*model.ReleaseMetadata, string, } return &releaseMetadata, readme.String(), nil } + +func (b *FilesystemBackend) UpdateModule(module *gen.Module) error { + // Convert module to JSON + data, err := json.Marshal(module) + if err != nil { + return err + } + + // Write to file + filename := filepath.Join(b.ModulesDir, module.Slug+".json") + return os.WriteFile(filename, data, 0644) +} diff --git a/internal/v3/backend/interface.go b/internal/v3/backend/interface.go index 9c2772c..5611038 100644 --- a/internal/v3/backend/interface.go +++ b/internal/v3/backend/interface.go @@ -26,4 +26,7 @@ type Backend interface { // DeleteReleaseBySlug deletes a release by slug DeleteReleaseBySlug(slug string) error + + // UpdateModule updates a module + UpdateModule(module *gen.Module) error } diff --git a/internal/v3/ui/components/statistics.templ b/internal/v3/ui/components/statistics.templ index 9859509..6146ca7 100644 --- a/internal/v3/ui/components/statistics.templ +++ b/internal/v3/ui/components/statistics.templ @@ -12,6 +12,8 @@ templ StatisticsView(stats *customMiddleware.Statistics) {

ActiveConnections: { strconv.Itoa(stats.ActiveConnections) }

TotalConnections: { strconv.Itoa(stats.TotalConnections) }

TotalResponseTime: { stats.TotalResponseTime.String() }

+

TotalCacheHits: { strconv.Itoa(stats.TotalCacheHits) }

+

TotalCacheMisses: { strconv.Itoa(stats.TotalCacheMisses) }

@@ -19,6 +21,7 @@ templ StatisticsView(stats *customMiddleware.Statistics) { + @@ -28,6 +31,11 @@ templ StatisticsView(stats *customMiddleware.Statistics) { + if stats.CacheHitsPerEndpoint[path] > 0 || stats.CacheMissesPerEndpoint[path] > 0 { + + } else { + + } } diff --git a/internal/v3/ui/components/statistics_templ.go b/internal/v3/ui/components/statistics_templ.go index b9129d0..534b1bc 100644 --- a/internal/v3/ui/components/statistics_templ.go +++ b/internal/v3/ui/components/statistics_templ.go @@ -71,7 +71,33 @@ func StatisticsView(stats *customMiddleware.Statistics) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Connections Average ResponseTime Total ResponseTimeCache (Hits/Misses)
{ strconv.Itoa(connections) } { (stats.ResponseTimePerEndpoint[path] / time.Duration(connections)).String() } { stats.ResponseTimePerEndpoint[path].String() }{ strconv.Itoa(stats.CacheHitsPerEndpoint[path]) }/{ strconv.Itoa(stats.CacheMissesPerEndpoint[path]) }N/A
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

TotalCacheHits: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(stats.TotalCacheHits)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 15, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

TotalCacheMisses: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(stats.TotalCacheMisses)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 16, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

PathConnectionsAverage ResponseTimeTotal ResponseTime
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -80,12 +106,12 @@ func StatisticsView(stats *customMiddleware.Statistics) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(path) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(path) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 27, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 30, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,12 +119,12 @@ func StatisticsView(stats *customMiddleware.Statistics) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(connections)) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(connections)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 28, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 31, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -106,12 +132,12 @@ func StatisticsView(stats *customMiddleware.Statistics) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs((stats.ResponseTimePerEndpoint[path] / time.Duration(connections)).String()) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs((stats.ResponseTimePerEndpoint[path] / time.Duration(connections)).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 29, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 32, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -119,16 +145,57 @@ func StatisticsView(stats *customMiddleware.Statistics) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ResponseTimePerEndpoint[path].String()) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ResponseTimePerEndpoint[path].String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 30, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 33, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if stats.CacheHitsPerEndpoint[path] > 0 || stats.CacheMissesPerEndpoint[path] > 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }
PathConnectionsAverage ResponseTimeTotal ResponseTimeCache (Hits/Misses)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(stats.CacheHitsPerEndpoint[path])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 35, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("/") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(stats.CacheMissesPerEndpoint[path])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/v3/ui/components/statistics.templ`, Line: 35, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("N/A