diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d562f6..ec8c58e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ jobs: strategy: fail-fast: false matrix: - go: ["1.15", "1.16", "1.17"] + go: ["1.16", "1.17", "1.18"] # Service containers to run with `container-job` services: diff --git a/README.md b/README.md index 5df9069..f9eee4c 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ Usage of bin/request-baskets: -token string Master token, random token is generated if not provided -basket value - Name of a basket to auto-create during service startup (can be specified multiple times) + Name of a basket to auto-create during service startup (can be specified multiple times) + -prefix string + Service URL path prefix ``` ### Parameters @@ -116,6 +118,7 @@ Usage of bin/request-baskets: * `-file` *location* - location of Bolt database file, only relevant if appropriate storage type is chosen * `-conn` *connection* - database connection string for SQL databases, if undefined `-file` argument is considered * `-basket` *value* - name of a basket to auto-create during service startup, this parameter can be specified multiple times + * `-prefix` *URL path prefix* - allows to host API and web-UI of baskets service under a sub-path instead of domain ROOT ## Usage diff --git a/config.go b/config.go index 9ec3d0e..14e3050 100644 --- a/config.go +++ b/config.go @@ -34,6 +34,7 @@ type ServerConfig struct { DbFile string DbConnection string Baskets []string + PathPrefix string } type arrayFlags []string @@ -59,10 +60,10 @@ func CreateConfig() *ServerConfig { "Baskets storage type: %s - in-memory, %s - Bolt DB, %s - SQL database", DbTypeMemory, DbTypeBolt, DbTypeSQL)) var dbFile = flag.String("file", "./baskets.db", "Database location, only applicable for file or SQL databases") var dbConnection = flag.String("conn", "", "Database connection string for SQL databases, if undefined \"file\" argument is considered") + var prefix = flag.String("prefix", "", "Service URL path prefix") var baskets arrayFlags flag.Var(&baskets, "basket", "Name of a basket to auto-create during service startup (can be specified multiple times)") - flag.Parse() var token = *masterToken @@ -81,5 +82,14 @@ func CreateConfig() *ServerConfig { DbType: *dbType, DbFile: *dbFile, DbConnection: *dbConnection, - Baskets: baskets} + Baskets: baskets, + PathPrefix: normalizePrefix(*prefix)} +} + +func normalizePrefix(prefix string) string { + if (len(prefix) > 0) && (prefix[0] != '/') { + return "/" + prefix + } else { + return prefix + } } diff --git a/config_test.go b/config_test.go index d9556dc..7726953 100644 --- a/config_test.go +++ b/config_test.go @@ -28,3 +28,11 @@ func TestArrayFlags(t *testing.T) { assert.Equal(t, "123,abc,xyz,abc", flags.String(), "unexpected list of flags") } + +func TestNormalizePrefix(t *testing.T) { + assert.Empty(t, normalizePrefix(""), "expected empty prefix after normalization") + assert.Equal(t, "/xyz", normalizePrefix("/xyz"), "unexpected result of normalization") + assert.Equal(t, "/abc", normalizePrefix("abc"), "unexpected result of normalization") + assert.Equal(t, "/services/baskets", normalizePrefix("services/baskets"), "unexpected result of normalization") + assert.Equal(t, "/abc/def/ghi", normalizePrefix("/abc/def/ghi"), "unexpected result of normalization") +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index afb349f..ff4d41d 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -42,6 +42,10 @@ if [ -n "$BASKET" ]; then args="$args -basket $BASKET" fi +if [ -n "$PATHPREFIX" ]; then + args="$args -prefix $PATHPREFIX" +fi + cmd="/bin/rbaskets $args" echo "Executing: $cmd" exec $cmd diff --git a/go.mod b/go.mod index 14f7a71..c9ce9fa 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-sql-driver/mysql v1.6.0 github.com/julienschmidt/httprouter v1.3.0 - github.com/lib/pq v1.10.3 + github.com/lib/pq v1.10.6 github.com/stretchr/testify v1.7.0 go.etcd.io/bbolt v1.3.6 - golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 98debb5..ae49cea 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,8 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= -github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= +github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -15,8 +15,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 h1:SeSEfdIxyvwGJliREIJhRPPXvW6sDlLT+UQ3B0hD0NA= -golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers.go b/handlers.go index 5790c61..7707098 100644 --- a/handlers.go +++ b/handlers.go @@ -343,13 +343,20 @@ func ClearBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { // ForwardToWeb handels HTTP forwarding to /web func ForwardToWeb(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - http.Redirect(w, r, "/"+serviceUIPath, http.StatusFound) + http.Redirect(w, r, pathPrefix+"/"+serviceUIPath, http.StatusFound) +} + +type TemplateData struct { + Prefix string + Version *Version + Basket string + Data interface{} } // WebIndexPage handles HTTP request to render index page func WebIndexPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - indexPageTemplate.Execute(w, version) + indexPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version}) } // WebBasketPage handles HTTP request to render basket details page @@ -359,9 +366,9 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) case serviceOldAPIPath: // admin page to access all baskets w.Header().Set("Content-Type", "text/html; charset=utf-8") - basketsPageTemplate.Execute(w, version) + basketsPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version}) default: - basketPageTemplate.Execute(w, name) + basketPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version, Basket: name}) } } else { http.Error(w, "Basket name does not match pattern: "+validBasketName.String(), http.StatusBadRequest) @@ -370,10 +377,10 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) // AcceptBasketRequests accepts and handles HTTP requests passed to different baskets func AcceptBasketRequests(w http.ResponseWriter, r *http.Request) { - name := strings.Split(r.URL.Path, "/")[1] - - if !validBasketName.MatchString(name) { - http.Error(w, "invalid basket name; the name does not match pattern: "+validBasketName.String(), http.StatusBadRequest) + name, publicErr, err := getBasketNameOfAcceptedRequest(r, pathPrefix) + if err != nil { + log.Printf("[error] %s", err) + http.Error(w, publicErr, http.StatusBadRequest) } else if basket := basketsDb.Get(name); basket != nil { request := basket.Add(r) @@ -394,6 +401,26 @@ func AcceptBasketRequests(w http.ResponseWriter, r *http.Request) { } } +func getBasketNameOfAcceptedRequest(r *http.Request, prefix string) (string, string, error) { + path := r.URL.Path + if len(prefix) > 0 { + if strings.HasPrefix(path, prefix) { + path = strings.TrimPrefix(path, prefix) + } else { + publicErr := "incoming request is outside of configured path prefix: " + prefix + return "", publicErr, fmt.Errorf("%s; request: %s %s", publicErr, r.Method, sanitizeForLog(r.URL.Path)) + } + } + + name := sanitizeForLog(strings.Split(path, "/")[1]) + if !validBasketName.MatchString(name) { + publicErr := "invalid basket name; the name does not match pattern: " + validBasketName.String() + return "", publicErr, fmt.Errorf("%s; request: %s %s", publicErr, r.Method, sanitizeForLog(r.URL.Path)) + } + + return name, "", nil +} + func forwardAndForget(request *RequestData, config BasketConfig, name string) { // forward request and discard the response response, err := request.Forward(getHTTPClient(config.InsecureTLS), config, name) @@ -460,3 +487,9 @@ func writeBasketResponse(w http.ResponseWriter, r *http.Request, name string, ba w.Write([]byte(response.Body)) } } + +func sanitizeForLog(raw string) string { + sanitized := strings.ReplaceAll(raw, "\n", "^n") + sanitized = strings.ReplaceAll(sanitized, "\r", "^r") + return sanitized +} diff --git a/handlers_test.go b/handlers_test.go index 22e90fd..ec0c6e5 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -1846,3 +1846,78 @@ func TestAcceptBasketRequests_WithProxyResponse_InternalServerError(t *testing.T } } } + +func TestGetBasketNameOfAcceptedRequest_NoPrefix_Valid(t *testing.T) { + r, err := http.NewRequest("GET", "http://localhost:55555/basket200", strings.NewReader("")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "") + assert.Equal(t, "basket200", name, "unexpected basket name") + assert.Empty(t, pubErr) + assert.Nil(t, err) + } +} + +func TestGetBasketNameOfAcceptedRequest_NoPrefix_ValidWithSubpath(t *testing.T) { + r, err := http.NewRequest("DELETE", "http://localhost:55555/basket210/api/users/123", strings.NewReader("")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "") + assert.Equal(t, "basket210", name, "unexpected basket name") + assert.Empty(t, pubErr) + assert.Nil(t, err) + } +} + +func TestGetBasketNameOfAcceptedRequest_NoPrefix_Invalid(t *testing.T) { + r, err := http.NewRequest("PUT", "http://localhost:55555/basket~220/objects/404", strings.NewReader("{}")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "") + assert.Empty(t, name, "basket name is invalid, hence unexpected") + assert.Equal(t, pubErr, "invalid basket name; the name does not match pattern: "+basketNamePattern) + if assert.NotNil(t, err) { + assert.Equal(t, err.Error(), "invalid basket name; the name does not match pattern: "+ + basketNamePattern+"; request: PUT /basket~220/objects/404") + } + } +} + +func TestGetBasketNameOfAcceptedRequest_WithPrefix_Valid(t *testing.T) { + r, err := http.NewRequest("GET", "http://localhost:55555/abc/basket300", strings.NewReader("")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/abc") + assert.Equal(t, "basket300", name, "unexpected basket name") + assert.Empty(t, pubErr) + assert.Nil(t, err) + } +} + +func TestGetBasketNameOfAcceptedRequest_WithPrefix_ValidWithSubpath(t *testing.T) { + r, err := http.NewRequest("PATCH", "http://localhost:55555/xyz/basket310/api/users/123", strings.NewReader("{}")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/xyz") + assert.Equal(t, "basket310", name, "unexpected basket name") + assert.Empty(t, pubErr) + assert.Nil(t, err) + } +} + +func TestGetBasketNameOfAcceptedRequest_WithPrefix_OutsideOfContext(t *testing.T) { + r, err := http.NewRequest("POST", "http://localhost:55555/api/objects", strings.NewReader("{}")) + if assert.NoError(t, err) { + name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/baskets") + assert.Empty(t, name, "URL is out of context, hence no basket name is expected") + assert.Equal(t, pubErr, "incoming request is outside of configured path prefix: /baskets") + if assert.NotNil(t, err) { + assert.Equal(t, err.Error(), "incoming request is outside of configured path prefix: /baskets"+ + "; request: POST /api/objects") + } + } +} + +func TestSanitizeForLog(t *testing.T) { + assert.Equal(t, "basket2346", sanitizeForLog("basket2346"), "unexpected result of sanitizing") + assert.Equal(t, "abc~!@#$%09381", sanitizeForLog("abc~!@#$%09381"), "unexpected result of sanitizing") + assert.Equal(t, "new line^n injection", sanitizeForLog("new line\n injection"), "unexpected result of sanitizing") + assert.Equal(t, "another^rnew line", sanitizeForLog("another\rnew line"), "unexpected result of sanitizing") + assert.Equal(t, "multi-^n^r^n^r^rmulti-^nmulti-^r^nlines", sanitizeForLog("multi-\n\r\n\r\rmulti-\nmulti-\r\nlines"), + "unexpected result of sanitizing") +} diff --git a/server.go b/server.go index b4d6837..297a6c0 100644 --- a/server.go +++ b/server.go @@ -16,6 +16,7 @@ var basketsDb BasketsDatabase var httpClient *http.Client var httpInsecureClient *http.Client var version *Version +var pathPrefix string // CreateServer creates an instance of Request Baskets server func CreateServer(config *ServerConfig) *http.Server { @@ -42,45 +43,47 @@ func CreateServer(config *ServerConfig) *http.Server { insecureTransport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} httpInsecureClient = &http.Client{Transport: insecureTransport} + setPathPrefix(config.PathPrefix) + // configure service HTTP router router := httprouter.New() //// Old API mapping //// // basket names - router.GET("/"+serviceOldAPIPath, GetBaskets) + router.GET(pathPrefix+"/"+serviceOldAPIPath, GetBaskets) // basket management - router.GET("/"+serviceOldAPIPath+"/:basket", GetBasket) - router.POST("/"+serviceOldAPIPath+"/:basket", CreateBasket) - router.PUT("/"+serviceOldAPIPath+"/:basket", UpdateBasket) - router.DELETE("/"+serviceOldAPIPath+"/:basket", DeleteBasket) - router.GET("/"+serviceOldAPIPath+"/:basket/responses/:method", GetBasketResponse) - router.PUT("/"+serviceOldAPIPath+"/:basket/responses/:method", UpdateBasketResponse) + router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket", GetBasket) + router.POST(pathPrefix+"/"+serviceOldAPIPath+"/:basket", CreateBasket) + router.PUT(pathPrefix+"/"+serviceOldAPIPath+"/:basket", UpdateBasket) + router.DELETE(pathPrefix+"/"+serviceOldAPIPath+"/:basket", DeleteBasket) + router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket/responses/:method", GetBasketResponse) + router.PUT(pathPrefix+"/"+serviceOldAPIPath+"/:basket/responses/:method", UpdateBasketResponse) // requests management - router.GET("/"+serviceOldAPIPath+"/:basket/requests", GetBasketRequests) - router.DELETE("/"+serviceOldAPIPath+"/:basket/requests", ClearBasket) + router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket/requests", GetBasketRequests) + router.DELETE(pathPrefix+"/"+serviceOldAPIPath+"/:basket/requests", ClearBasket) //// New API mapping //// // service details - router.GET("/"+serviceAPIPath+"/stats", GetStats) - router.GET("/"+serviceAPIPath+"/version", GetVersion) + router.GET(pathPrefix+"/"+serviceAPIPath+"/stats", GetStats) + router.GET(pathPrefix+"/"+serviceAPIPath+"/version", GetVersion) // basket names - router.GET("/"+serviceAPIPath+"/baskets", GetBaskets) + router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets", GetBaskets) // basket management - router.GET("/"+serviceAPIPath+"/baskets/:basket", GetBasket) - router.POST("/"+serviceAPIPath+"/baskets/:basket", CreateBasket) - router.PUT("/"+serviceAPIPath+"/baskets/:basket", UpdateBasket) - router.DELETE("/"+serviceAPIPath+"/baskets/:basket", DeleteBasket) - router.GET("/"+serviceAPIPath+"/baskets/:basket/responses/:method", GetBasketResponse) - router.PUT("/"+serviceAPIPath+"/baskets/:basket/responses/:method", UpdateBasketResponse) + router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", GetBasket) + router.POST(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", CreateBasket) + router.PUT(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", UpdateBasket) + router.DELETE(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", DeleteBasket) + router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/responses/:method", GetBasketResponse) + router.PUT(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/responses/:method", UpdateBasketResponse) // requests management - router.GET("/"+serviceAPIPath+"/baskets/:basket/requests", GetBasketRequests) - router.DELETE("/"+serviceAPIPath+"/baskets/:basket/requests", ClearBasket) + router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/requests", GetBasketRequests) + router.DELETE(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/requests", ClearBasket) // web pages - router.GET("/", ForwardToWeb) - router.GET("/"+serviceUIPath, WebIndexPage) - router.GET("/"+serviceUIPath+"/:basket", WebBasketPage) - //router.ServeFiles("/"+serviceUIPath+"/*filepath", http.Dir("./src/github.com/darklynx/request-baskets/web")) + router.GET(pathPrefix+"/", ForwardToWeb) + router.GET(pathPrefix+"/"+serviceUIPath, WebIndexPage) + router.GET(pathPrefix+"/"+serviceUIPath+"/:basket", WebBasketPage) + //router.ServeFiles(pathPrefix+"/"+serviceUIPath+"/*filepath", http.Dir("./web")) // basket requests router.NotFound = http.HandlerFunc(AcceptBasketRequests) @@ -109,6 +112,13 @@ func createBasketsDatabase(dbtype string, file string, conn string) BasketsDatab } } +func setPathPrefix(prefix string) { + pathPrefix = prefix + if len(pathPrefix) > 0 { + log.Printf("[info] service path prefix: %s", pathPrefix) + } +} + func shutdownHook() { sigs := make(chan os.Signal, 1) done := make(chan bool, 1) diff --git a/server_test.go b/server_test.go index 71538cc..798934d 100644 --- a/server_test.go +++ b/server_test.go @@ -78,3 +78,11 @@ func TestCreateDefaultBaskets(t *testing.T) { assert.Equal(t, serverConfig.InitCapacity, db.Get("abc").Config().Capacity, "unexpected basket capacity") } + +func TestSetPathPrefix(t *testing.T) { + setPathPrefix("/abc") + assert.Equal(t, "/abc", pathPrefix, "unexpected prefix") + + setPathPrefix("") + assert.Empty(t, pathPrefix, "prefix is not expected") +} diff --git a/web/basket.html b/web/basket.html index 5ac87b7..f7e5a80 100644 --- a/web/basket.html +++ b/web/basket.html @@ -22,15 +22,25 @@