From bfeceb0ed298c4c977707a847aba342e1e97bd80 Mon Sep 17 00:00:00 2001 From: Vibhu Pandey Date: Thu, 22 Aug 2024 20:56:15 +0530 Subject: [PATCH] feat(web): add web package (#5743) ### Summary Add a web package for serving frontend #### Related Issues / PR's https://github.com/SigNoz/signoz/pull/5710 --- pkg/config/config.go | 4 + pkg/config/config_test.go | 9 +- pkg/http/middleware/cache.go | 28 ++++++ pkg/http/middleware/cache_test.go | 56 +++++++++++ pkg/web/config.go | 29 ++++++ pkg/web/testdata/index.html | 1 + pkg/web/web.go | 94 ++++++++++++++++++ pkg/web/web_test.go | 159 ++++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 pkg/http/middleware/cache.go create mode 100644 pkg/http/middleware/cache_test.go create mode 100644 pkg/web/config.go create mode 100644 pkg/web/testdata/index.html create mode 100644 pkg/web/web.go create mode 100644 pkg/web/web_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 621cc073c6..02cc7c37c2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,11 +4,13 @@ import ( "context" "go.signoz.io/signoz/pkg/instrumentation" + "go.signoz.io/signoz/pkg/web" ) // Config defines the entire configuration of signoz. type Config struct { Instrumentation instrumentation.Config `mapstructure:"instrumentation"` + Web web.Config `mapstructure:"web"` } func New(ctx context.Context, settings ProviderSettings) (*Config, error) { @@ -24,6 +26,8 @@ func byName(name string) (any, bool) { switch name { case "instrumentation": return &instrumentation.Config{}, true + case "web": + return &web.Config{}, true default: return nil, false } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 04ede418f0..b3e3007bb4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -10,12 +10,15 @@ import ( contribsdkconfig "go.opentelemetry.io/contrib/config" "go.signoz.io/signoz/pkg/confmap/provider/signozenvprovider" "go.signoz.io/signoz/pkg/instrumentation" + "go.signoz.io/signoz/pkg/web" ) func TestNewWithSignozEnvProvider(t *testing.T) { t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__ENABLED", "true") t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__PROCESSORS__BATCH__EXPORTER__OTLP__ENDPOINT", "0.0.0.0:4317") t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__PROCESSORS__BATCH__EXPORT_TIMEOUT", "10") + t.Setenv("SIGNOZ__WEB__PREFIX", "/web") + t.Setenv("SIGNOZ__WEB__DIRECTORY", "/build") config, err := New(context.Background(), ProviderSettings{ ResolverSettings: confmap.ResolverSettings{ @@ -34,7 +37,7 @@ func TestNewWithSignozEnvProvider(t *testing.T) { Enabled: true, LoggerProvider: contribsdkconfig.LoggerProvider{ Processors: []contribsdkconfig.LogRecordProcessor{ - contribsdkconfig.LogRecordProcessor{ + { Batch: &contribsdkconfig.BatchLogRecordProcessor{ ExportTimeout: &i, Exporter: contribsdkconfig.LogRecordExporter{ @@ -48,6 +51,10 @@ func TestNewWithSignozEnvProvider(t *testing.T) { }, }, }, + Web: web.Config{ + Prefix: "/web", + Directory: "/build", + }, } assert.Equal(t, expected, config) diff --git a/pkg/http/middleware/cache.go b/pkg/http/middleware/cache.go new file mode 100644 index 0000000000..ff66288354 --- /dev/null +++ b/pkg/http/middleware/cache.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + "strconv" + "time" +) + +type Cache struct { + maxAge time.Duration +} + +func NewCache(maxAge time.Duration) *Cache { + if maxAge == 0 { + maxAge = 7 * 24 * time.Hour + } + + return &Cache{ + maxAge: maxAge, + } +} + +func (middleware *Cache) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Cache-Control", "max-age="+strconv.Itoa(int(middleware.maxAge.Seconds()))) + next.ServeHTTP(rw, req) + }) +} diff --git a/pkg/http/middleware/cache_test.go b/pkg/http/middleware/cache_test.go new file mode 100644 index 0000000000..bef0e3b36b --- /dev/null +++ b/pkg/http/middleware/cache_test.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "net" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCache(t *testing.T) { + t.Parallel() + + age := 20 * 24 * time.Hour + m := NewCache(age) + + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + server := &http.Server{ + Handler: m.Wrap(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(204) + })), + } + + go func() { + require.NoError(t, server.Serve(listener)) + }() + + testCases := []struct { + name string + age time.Duration + }{ + { + name: "Success", + age: age, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "http://"+listener.Addr().String(), nil) + require.NoError(t, err) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + actual := res.Header.Get("Cache-control") + require.NoError(t, err) + + require.Equal(t, "max-age="+strconv.Itoa(int(age.Seconds())), string(actual)) + }) + } +} diff --git a/pkg/web/config.go b/pkg/web/config.go new file mode 100644 index 0000000000..a0e0c531de --- /dev/null +++ b/pkg/web/config.go @@ -0,0 +1,29 @@ +package web + +import ( + "go.signoz.io/signoz/pkg/confmap" +) + +// Config satisfies the confmap.Config interface +var _ confmap.Config = (*Config)(nil) + +// Config holds the configuration for web. +type Config struct { + // The prefix to serve the files from + Prefix string `mapstructure:"prefix"` + // The directory containing the static build files. The root of this directory should + // have an index.html file. + Directory string `mapstructure:"directory"` +} + +func (c *Config) NewWithDefaults() confmap.Config { + return &Config{ + Prefix: "/", + Directory: "/etc/signoz/web", + } + +} + +func (c *Config) Validate() error { + return nil +} diff --git a/pkg/web/testdata/index.html b/pkg/web/testdata/index.html new file mode 100644 index 0000000000..49c8c8383a --- /dev/null +++ b/pkg/web/testdata/index.html @@ -0,0 +1 @@ +

Welcome to test data!!!

\ No newline at end of file diff --git a/pkg/web/web.go b/pkg/web/web.go new file mode 100644 index 0000000000..2e29b000f9 --- /dev/null +++ b/pkg/web/web.go @@ -0,0 +1,94 @@ +package web + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/gorilla/mux" + "go.signoz.io/signoz/pkg/http/middleware" + "go.uber.org/zap" +) + +var _ http.Handler = (*Web)(nil) + +const ( + indexFileName string = "index.html" +) + +type Web struct { + logger *zap.Logger + cfg Config +} + +func New(logger *zap.Logger, cfg Config) (*Web, error) { + if logger == nil { + return nil, fmt.Errorf("cannot build web, logger is required") + } + + fi, err := os.Stat(cfg.Directory) + if err != nil { + return nil, fmt.Errorf("cannot access web directory: %w", err) + } + + ok := fi.IsDir() + if !ok { + return nil, fmt.Errorf("web directory is not a directory") + } + + fi, err = os.Stat(filepath.Join(cfg.Directory, indexFileName)) + if err != nil { + return nil, fmt.Errorf("cannot access %q in web directory: %w", indexFileName, err) + } + + if os.IsNotExist(err) || fi.IsDir() { + return nil, fmt.Errorf("%q does not exist", indexFileName) + } + + return &Web{ + logger: logger.Named("go.signoz.io/pkg/web"), + cfg: cfg, + }, nil +} + +func (web *Web) AddToRouter(router *mux.Router) error { + cache := middleware.NewCache(7 * 24 * time.Hour) + err := router.PathPrefix(web.cfg.Prefix). + Handler( + http.StripPrefix( + web.cfg.Prefix, + cache.Wrap(http.HandlerFunc(web.ServeHTTP)), + ), + ).GetError() + if err != nil { + return fmt.Errorf("unable to add web to router: %w", err) + } + + return nil +} + +func (web *Web) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + // Join internally call path.Clean to prevent directory traversal + path := filepath.Join(web.cfg.Directory, req.URL.Path) + + // check whether a file exists or is a directory at the given path + fi, err := os.Stat(path) + if os.IsNotExist(err) || fi.IsDir() { + // file does not exist or path is a directory, serve index.html + http.ServeFile(rw, req, filepath.Join(web.cfg.Directory, indexFileName)) + return + } + + if err != nil { + // if we got an error (that wasn't that the file doesn't exist) stating the + // file, return a 500 internal server error and stop + // TODO: Put down a crash html page here + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + // otherwise, use http.FileServer to serve the static file + http.FileServer(http.Dir(web.cfg.Directory)).ServeHTTP(rw, req) +} diff --git a/pkg/web/web_test.go b/pkg/web/web_test.go new file mode 100644 index 0000000000..d5111cf747 --- /dev/null +++ b/pkg/web/web_test.go @@ -0,0 +1,159 @@ +package web + +import ( + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestServeHttpWithoutPrefix(t *testing.T) { + t.Parallel() + fi, err := os.Open(filepath.Join("testdata", indexFileName)) + require.NoError(t, err) + + expected, err := io.ReadAll(fi) + require.NoError(t, err) + + web, err := New(zap.NewNop(), Config{Prefix: "/", Directory: filepath.Join("testdata")}) + require.NoError(t, err) + + router := mux.NewRouter() + err = web.AddToRouter(router) + require.NoError(t, err) + + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + server := &http.Server{ + Handler: router, + } + + go func() { + _ = server.Serve(listener) + }() + defer func() { + _ = server.Close() + }() + + testCases := []struct { + name string + path string + }{ + { + name: "Root", + path: "/", + }, + { + name: "Index", + path: "/" + indexFileName, + }, + { + name: "DoesNotExist", + path: "/does-not-exist", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path) + require.NoError(t, err) + + defer func() { + _ = res.Body.Close() + }() + + actual, err := io.ReadAll(res.Body) + require.NoError(t, err) + + assert.Equal(t, expected, actual) + }) + } + +} + +func TestServeHttpWithPrefix(t *testing.T) { + t.Parallel() + fi, err := os.Open(filepath.Join("testdata", indexFileName)) + require.NoError(t, err) + + expected, err := io.ReadAll(fi) + require.NoError(t, err) + + web, err := New(zap.NewNop(), Config{Prefix: "/web", Directory: filepath.Join("testdata")}) + require.NoError(t, err) + + router := mux.NewRouter() + err = web.AddToRouter(router) + require.NoError(t, err) + + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + server := &http.Server{ + Handler: router, + } + + go func() { + _ = server.Serve(listener) + }() + defer func() { + _ = server.Close() + }() + + testCases := []struct { + name string + path string + found bool + }{ + { + name: "Root", + path: "/web", + found: true, + }, + { + name: "Index", + path: "/web/" + indexFileName, + found: true, + }, + { + name: "FileDoesNotExist", + path: "/web/does-not-exist", + found: true, + }, + { + name: "DoesNotExist", + path: "/does-not-exist", + found: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path) + require.NoError(t, err) + + defer func() { + _ = res.Body.Close() + }() + + if tc.found { + actual, err := io.ReadAll(res.Body) + require.NoError(t, err) + + assert.Equal(t, expected, actual) + } else { + assert.Equal(t, http.StatusNotFound, res.StatusCode) + } + + }) + } + +}