diff --git a/.github/templates/README.template.md b/.github/templates/README.template.md index 9df84c21..f3066e4a 100644 --- a/.github/templates/README.template.md +++ b/.github/templates/README.template.md @@ -230,28 +230,29 @@ example: To improve compatibility with other services Secured Signal API provides aliases for the `message` attribute by default: -| Alias | Priority | -| ----------- | -------- | -| msg | 100 | -| content | 99 | -| description | 98 | -| text | 20 | -| body | 15 | -| summary | 10 | -| details | 9 | -| payload | 2 | -| data | 1 | - -Secured Signal API will use the highest priority Message Alias to extract the correct message from the Request Body. - -Message Aliases can be added by setting `MESSAGE_ALIASES`: +| Alias | Score | +| ----------- | ----- | +| msg | 100 | +| content | 99 | +| description | 98 | +| text | 20 | +| body | 15 | +| summary | 10 | +| details | 9 | +| payload | 2 | +| data | 1 | + +Secured Signal API will pick the best scoring Message Alias (if available) to extract the correct message from the Request Body. + +Message Aliases can be added by setting `MESSAGE_ALIASES` to a valid json array containing dictionaries of `alias`, the json key to be used for lookup (use `.` dots for using values from a nested dictionary and `[i]` to get values from an array): ```yaml environment: MESSAGE_ALIASES: | [ - { "alias": "note", "priority": 4 }, - { "alias": "test", "priority": 3 } + { "alias": "msg", "score": 80 }, + { "alias": "data.message", "score": 79 }, + { "alias": "array[0].message", "score": 78 }, ] ``` diff --git a/internals/proxy/middlewares/auth.go b/internals/proxy/middlewares/auth.go index ba3417ad..a5676aaa 100644 --- a/internals/proxy/middlewares/auth.go +++ b/internals/proxy/middlewares/auth.go @@ -3,7 +3,6 @@ package middlewares import ( "encoding/base64" "net/http" - "net/url" "slices" "strings" @@ -64,32 +63,32 @@ func (data AuthMiddleware) Use() http.Handler { authToken := authBody[1] switch authType { - case Bearer: - if isValidToken(tokens, authToken) { - success = true - } + case Bearer: + if isValidToken(tokens, authToken) { + success = true + } - case Basic: - basicAuthBody, err := base64.StdEncoding.DecodeString(authToken) + case Basic: + basicAuthBody, err := base64.StdEncoding.DecodeString(authToken) - if err != nil { - log.Error("Could not decode Basic Auth Payload: ", err.Error()) - } + if err != nil { + log.Error("Could not decode Basic Auth Payload: ", err.Error()) + } - basicAuth := string(basicAuthBody) - basicAuthParams := strings.Split(basicAuth, ":") + basicAuth := string(basicAuthBody) + basicAuthParams := strings.Split(basicAuth, ":") - user := "api" + user := "api" - if basicAuthParams[0] == user && isValidToken(tokens, basicAuthParams[1]) { - success = true - } + if basicAuthParams[0] == user && isValidToken(tokens, basicAuthParams[1]) { + success = true + } } } else if authQuery != "" { authType = Query - authToken, _ := url.QueryUnescape(authQuery) + authToken := strings.TrimSpace(authQuery) if isValidToken(tokens, authToken) { success = true diff --git a/internals/proxy/middlewares/body.go b/internals/proxy/middlewares/body.go index a4f245ff..d3888e34 100644 --- a/internals/proxy/middlewares/body.go +++ b/internals/proxy/middlewares/body.go @@ -6,13 +6,14 @@ import ( "net/http" "strconv" + "github.com/codeshelldev/secured-signal-api/utils" log "github.com/codeshelldev/secured-signal-api/utils/logger" request "github.com/codeshelldev/secured-signal-api/utils/request" ) type MessageAlias struct { Alias string - Priority int + Score int } type BodyMiddleware struct { @@ -74,17 +75,28 @@ func getMessage(aliases []MessageAlias, data map[string]interface{}) (string, ma var best int for _, alias := range aliases { - aliasKey := alias.Alias - priority := alias.Priority + aliasValue, score, ok := processAlias(alias, data) - value, ok := data[aliasKey] - - if ok && value != "" && priority > best { - content = data[aliasKey].(string) + if ok && score > best { + content = aliasValue } - data[aliasKey] = nil + data[alias.Alias] = nil } return content, data +} + +func processAlias(alias MessageAlias, data map[string]interface{}) (string, int, bool) { + aliasKey := alias.Alias + + value, ok := utils.GetJsonByPath(aliasKey, data) + + aliasValue, isStr := value.(string) + + if isStr && ok && aliasValue != "" { + return aliasValue, alias.Score, true + } else { + return "", 0, false + } } \ No newline at end of file diff --git a/internals/proxy/middlewares/template.go b/internals/proxy/middlewares/template.go index 0bd52767..bb7e67a6 100644 --- a/internals/proxy/middlewares/template.go +++ b/internals/proxy/middlewares/template.go @@ -2,18 +2,16 @@ package middlewares import ( "bytes" - "encoding/json" "io" "net/http" "net/url" - "regexp" "strconv" - "strings" - "text/template" + "github.com/codeshelldev/secured-signal-api/utils" log "github.com/codeshelldev/secured-signal-api/utils/logger" - query "github.com/codeshelldev/secured-signal-api/utils/query" + "github.com/codeshelldev/secured-signal-api/utils/query" request "github.com/codeshelldev/secured-signal-api/utils/request" + "github.com/codeshelldev/secured-signal-api/utils/templating" ) type TemplateMiddleware struct { @@ -39,7 +37,11 @@ func (data TemplateMiddleware) Use() http.Handler { if !body.Empty { var modified bool - bodyData, modified = templateJSON(body.Data, VARIABLES) + bodyData, modified, err = TemplateBody(body.Data, VARIABLES) + + if err != nil { + log.Error("Error Templating JSON: ", err.Error()) + } if modified { modifiedBody = true @@ -49,7 +51,11 @@ func (data TemplateMiddleware) Use() http.Handler { if req.URL.RawQuery != "" { var modified bool - req.URL.RawQuery, bodyData, modified = templateQuery(bodyData, req.URL, VARIABLES) + req.URL.RawQuery, bodyData, modified, err = TemplateQuery(req.URL, bodyData, VARIABLES) + + if err != nil { + log.Error("Error Templating Query: ", err.Error()) + } if modified { modifiedBody = true @@ -76,128 +82,83 @@ func (data TemplateMiddleware) Use() http.Handler { req.Body = io.NopCloser(bytes.NewReader(body.Raw)) - req.URL.Path, _ = templatePath(req.URL, VARIABLES) - - next.ServeHTTP(w, req) - }) -} - -func renderTemplate(name string, tmplStr string, data any) (string, error) { - tmpl, err := template.New(name).Parse(tmplStr) - - if err != nil { - return "", err - } - var buf bytes.Buffer - - err = tmpl.Execute(&buf, data) - - if err != nil { - return "", err - } - return buf.String(), nil -} - -func templateJSON(data map[string]interface{}, variables map[string]interface{}) (map[string]interface{}, bool) { - var modified bool - - for k, v := range data { - str, ok := v.(string) + if req.URL.Path != "" { + var modified bool - if ok { - re, err := regexp.Compile(`{{\s*\.([A-Za-z_][A-Za-z0-9_]*)\s*}}`) + req.URL.Path, modified, err = TemplatePath(req.URL, VARIABLES) if err != nil { - log.Error("Error while Compiling Regex: ", err.Error()) + log.Error("Error Templating Path: ", err.Error()) } - matches := re.FindAllStringSubmatch(str, -1) - - if len(matches) > 1 { - for i, tmplStr := range matches { - - tmplKey := matches[i][1] + if modified { + log.Debug("Applied Path Templating: ", req.URL.Path) + } + } - variable, err := json.Marshal(variables[tmplKey]) + next.ServeHTTP(w, req) + }) +} - if err != nil { - log.Error("Could not decode JSON: ", err.Error()) - break - } +func TemplateBody(data map[string]interface{}, VARIABLES any) (map[string]interface{}, bool, error) { + var modified bool - data[k] = strings.ReplaceAll(str, string(variable), tmplStr[0]) - } + templatedData, err := templating.RenderJSONTemplate("body", data, VARIABLES) - modified = true - } else if len(matches) == 1 { - tmplKey := matches[0][1] + if err != nil { + return data, false, err + } - data[k] = variables[tmplKey] + beforeStr := utils.ToJson(templatedData) + afterStr := utils.ToJson(data) - modified = true - } - } - } + modified = beforeStr == afterStr - return data, modified + return templatedData, modified, nil } -func templatePath(reqUrl *url.URL, VARIABLES interface{}) (string, bool) { +func TemplatePath(reqUrl *url.URL, VARIABLES any) (string, bool, error) { var modified bool reqPath, err := url.PathUnescape(reqUrl.Path) if err != nil { - log.Error("Error while Escaping Path: ", err.Error()) - return reqUrl.Path, modified + return reqUrl.Path, modified, err } - reqPath, err = renderTemplate("path", reqPath, VARIABLES) + reqPath, err = templating.RenderNormalizedTemplate("path", reqPath, VARIABLES) if err != nil { - log.Error("Could not Template Path: ", err.Error()) - return reqUrl.Path, modified + return reqUrl.Path, modified, err } if reqUrl.Path != reqPath { - log.Debug("Applied Path Templating: ", reqPath) - modified = true } - return reqPath, modified + return reqPath, modified, nil } -func templateQuery(data map[string]interface{}, reqUrl *url.URL, VARIABLES interface{}) (string, map[string]interface{}, bool) { +func TemplateQuery(reqUrl *url.URL, data map[string]interface{}, VARIABLES any) (string, map[string]interface{}, bool, error) { var modified bool decodedQuery, _ := url.QueryUnescape(reqUrl.RawQuery) - log.Debug("Decoded Query: ", decodedQuery) - - templatedQuery, _ := renderTemplate("query", decodedQuery, VARIABLES) + templatedQuery, _ := templating.RenderNormalizedTemplate("query", decodedQuery, VARIABLES) - modifiedQuery := reqUrl.Query() + originalQueryData := reqUrl.Query() - queryData := query.ParseRawQuery(templatedQuery) + addedData := query.ParseTypedQuery(templatedQuery, "@") - for key, value := range queryData { - keyWithoutPrefix, found := strings.CutPrefix(key, "@") + for key, val := range addedData { + data[key] = val - if found { - data[keyWithoutPrefix] = query.ParseTypedQuery(value) - - modifiedQuery.Del(key) - } - } - - reqRawQuery := modifiedQuery.Encode() - - if reqUrl.Query().Encode() != reqRawQuery { - log.Debug("Applied Query Templating: ", templatedQuery) + originalQueryData.Del(key) modified = true } - return reqRawQuery, data, modified + reqRawQuery := originalQueryData.Encode() + + return reqRawQuery, data, modified, nil } \ No newline at end of file diff --git a/tests/json_test.go b/tests/json_test.go new file mode 100644 index 00000000..b08b9ef5 --- /dev/null +++ b/tests/json_test.go @@ -0,0 +1,116 @@ +package tests + +import ( + "testing" + + "github.com/codeshelldev/secured-signal-api/utils" + "github.com/codeshelldev/secured-signal-api/utils/templating" +) + +func TestJsonTemplating(t *testing.T) { + variables := map[string]interface{}{ + "array": []string{ + "item0", + "item1", + }, + "key": "val", + "int": 4, + } + + json := ` + { + "dict": { "key": "{{.key}}" }, + "dictArray": [ + { "key": "{{.key}}" }, + { "key": "{{.array}}" } + ], + "key1": "{{.array}}", + "key2": "{{.int}}" + }` + + data := utils.GetJson[map[string]interface{}](json) + + expected := map[string]interface{}{ + "dict": map[string]interface{}{ + "key": "val", + }, + "dictArray": []interface{}{ + map[string]interface{}{"key": "val"}, + map[string]interface{}{"key": []interface{}{ "item0", "item1" }}, + }, + "key1": []interface{}{ "item0", "item1" }, + "key2": 4, + } + + got, err := templating.RenderJSONTemplate("json", data, variables) + + if err != nil { + t.Error("Error Templating JSON: ", err.Error()) + } + + expectedStr := utils.ToJson(expected) + gotStr := utils.ToJson(got) + + if expectedStr != gotStr { + t.Error("\nExpected: ", expectedStr, "\nGot: ", gotStr) + } +} + +func TestJsonPath(t *testing.T) { + json := ` + { + "dict": { "key": "value" }, + "dictArray": [ + { "key": "value0" }, + { "key": "value1" } + ], + "array": [ + "item0", + "item1" + ], + "key": "val" + }` + + data := utils.GetJson[map[string]interface{}](json) + + cases := []struct{ + key string + expected string + }{ + { + key: "key", + expected: "val", + }, + { + key: "dict.key", + expected: "value", + }, + { + key: "dictArray[0].key", + expected: "value0", + }, + { + key: "dictArray[1].key", + expected: "value1", + }, + { + key: "array[0]", + expected: "item0", + }, + { + key: "array[1]", + expected: "item1", + }, + } + + for _, c := range cases { + key := c.key + expected := c.expected + + got, ok := utils.GetJsonByPath(key, data) + + if !ok || got.(string) != expected { + t.Error("Expected: ", key, " == ", expected, "; Got: ", got) + } + } +} \ No newline at end of file diff --git a/tests/request_test.go b/tests/request_test.go new file mode 100644 index 00000000..bfa016ec --- /dev/null +++ b/tests/request_test.go @@ -0,0 +1,54 @@ +package tests + +import ( + "testing" + + "github.com/codeshelldev/secured-signal-api/utils" + "github.com/codeshelldev/secured-signal-api/utils/query" + "github.com/codeshelldev/secured-signal-api/utils/templating" +) + +func TestQueryTemplating(t *testing.T) { + variables := map[string]interface{}{ + "value": "helloworld", + "array": []string{ + "hello", + "world", + }, + } + + queryStr := "key={{.value}}&array={{.array}}" + + got, err := templating.RenderNormalizedTemplate("query", queryStr, variables) + + if err != nil { + t.Error("Error Templating Query: ", err.Error()) + } + + expected := "key=helloworld&array=[hello,world]" + + if got != expected { + t.Error("Expected: ", expected, "; Got: ", got) + } +} + +func TestTypedQuery(t *testing.T) { + queryStr := "key=helloworld&array=[hello,world]&int=1" + + got := query.ParseTypedQuery(queryStr, "") + + expected := map[string]interface{}{ + "key": "helloworld", + "int": 1, + "array": []string{ + "hello", "world", + }, + } + + expectedStr := utils.ToJson(expected) + gotStr := utils.ToJson(got) + + if expectedStr != gotStr { + t.Error("\nExpected: ", expectedStr, "\nGot: ", gotStr) + } +} \ No newline at end of file diff --git a/utils/env/env.go b/utils/env/env.go index 4bfce983..80657b87 100644 --- a/utils/env/env.go +++ b/utils/env/env.go @@ -38,39 +38,39 @@ var ENV ENV_ = ENV_{ MESSAGE_ALIASES: []middlewares.MessageAlias{ { Alias: "msg", - Priority: 100, + Score: 100, }, { Alias: "content", - Priority: 99, + Score: 99, }, { Alias: "description", - Priority: 98, + Score: 98, }, { Alias: "text", - Priority: 20, + Score: 20, }, { Alias: "body", - Priority: 15, + Score: 15, }, { Alias: "summary", - Priority: 10, + Score: 10, }, { Alias: "details", - Priority: 9, + Score: 9, }, { Alias: "payload", - Priority: 2, + Score: 2, }, { Alias: "data", - Priority: 1, + Score: 1, }, }, } diff --git a/utils/query/query.go b/utils/query/query.go index 04b00da0..4fc3a55c 100644 --- a/utils/query/query.go +++ b/utils/query/query.go @@ -44,7 +44,7 @@ func tryParseInt(str string) (int, bool) { return 0, false } -func ParseTypedQuery(values []string) interface{} { +func ParseTypedQueryValues(values []string) interface{} { var result interface{} raw := values[0] @@ -80,3 +80,21 @@ func ParseTypedQuery(values []string) interface{} { return result } + +func ParseTypedQuery(query string, matchPrefix string) (map[string]interface{}) { + addedData := map[string]interface{}{} + + queryData := ParseRawQuery(query) + + for key, value := range queryData { + keyWithoutPrefix, match := strings.CutPrefix(key, matchPrefix) + + if match { + newValue := ParseTypedQueryValues(value) + + addedData[keyWithoutPrefix] = newValue + } + } + + return addedData +} \ No newline at end of file diff --git a/utils/request/request.go b/utils/request/request.go index 9e5c12ca..723e5860 100644 --- a/utils/request/request.go +++ b/utils/request/request.go @@ -76,7 +76,7 @@ func GetFormData(body []byte) (map[string]interface{}, error) { } for key, value := range queryData { - data[key] = query.ParseTypedQuery(value) + data[key] = query.ParseTypedQueryValues(value) } return data, nil diff --git a/utils/templating/templating.go b/utils/templating/templating.go new file mode 100644 index 00000000..70f8710a --- /dev/null +++ b/utils/templating/templating.go @@ -0,0 +1,123 @@ +package templating + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strings" + "text/template" +) + +func normalize(value interface{}) string { + switch str := value.(type) { + case []string: + return "[" + strings.Join(str, ",") + "]" + + case []interface{}: + items := make([]string, len(str)) + + for i, item := range str { + items[i] = fmt.Sprintf("%v", item) + } + + return "[" + strings.Join(items, ",") + "]" + default: + return fmt.Sprintf("%v", value) + } +} + +func normalizeJSON(value interface{}) string { + jsonBytes, err := json.Marshal(value) + + if err != nil { + return "INVALID:JSON" + } + + return "<<" + string(jsonBytes) + ">>" +} + +func ParseTemplate(templt *template.Template, tmplStr string, variables any) (string, error) { + tmpl, err := templt.Parse(tmplStr) + + if err != nil { + return "", err + } + var buf bytes.Buffer + + err = tmpl.Execute(&buf, variables) + + if err != nil { + return "", err + } + return buf.String(), nil +} + +func RenderTemplate(name string, tmplStr string, variables any) (string, error) { + templt := template.New(name) + + return ParseTemplate(templt, tmplStr, variables) +} + +func CreateTemplateWithFunc(name string, funcMap template.FuncMap) (*template.Template) { + return template.New(name).Funcs(funcMap) +} + +func RenderJSONTemplate(name string, data map[string]interface{}, variables any) (map[string]interface{}, error) { + jsonBytes, err := json.Marshal(data) + + if err != nil { + return nil, err + } + + tmplStr := string(jsonBytes) + + re, err := regexp.Compile(`{{\s*\.(\w+)\s*}}`) + + // Add normalize() to be able to remove Quotes from Arrays + if err == nil { + tmplStr = re.ReplaceAllString(tmplStr, "{{normalize .$1}}") + } + + templt := CreateTemplateWithFunc(name, template.FuncMap{ + "normalize": normalizeJSON, + }) + + jsonStr, err := ParseTemplate(templt, tmplStr, variables) + + if err != nil { + return nil, err + } + + // Remove the Quotes around "<<[item1,item2]>>" + re, err = regexp.Compile(`"<<(.*?)>>"`) + + if err != nil { + return nil, err + } + + jsonStr = re.ReplaceAllString(jsonStr, "$1") + + err = json.Unmarshal([]byte(jsonStr), &data) + + if err != nil { + return nil, err + } + + return data, nil +} + +func RenderNormalizedTemplate(name string, tmplStr string, variables any) (string, error) { + re, err := regexp.Compile(`{{\s*\.(\w+)\s*}}`) + + // Add normalize() to normalize arrays to [item1,item2] + if err == nil { + tmplStr = re.ReplaceAllString(tmplStr, "{{normalize .$1}}") + } + + templt := CreateTemplateWithFunc(name, template.FuncMap{ + "normalize": normalize, + }) + + return ParseTemplate(templt, tmplStr, variables) +} \ No newline at end of file diff --git a/utils/utils.go b/utils/utils.go index 181ff954..cd746b33 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -7,6 +7,8 @@ package utils import ( "encoding/json" + "regexp" + "strconv" "strings" ) @@ -28,6 +30,49 @@ func StringToArray(sliceStr string) []string { return items } +func GetJsonByPath(path string, data interface{}) (interface{}, bool) { + // Split into parts by `.` and `[]` + re := regexp.MustCompile(`\.|\[|\]`) + + parts := re.Split(path, -1) + + cleaned := []string{} + + for _, part := range parts { + if part != "" { + cleaned = append(cleaned, part) + } + } + + current := data + + for _, key := range cleaned { + switch currentDataType := current.(type) { + // Case: Dictionary + case map[string]interface{}: + value, ok := currentDataType[key] + if !ok { + return nil, false + } + current = value + + // Case: Array + case []interface{}: + index, err := strconv.Atoi(key) + + if err != nil || index < 0 || index >= len(currentDataType) { + return nil, false + } + current = currentDataType[index] + + default: + return nil, false + } + } + + return current, true +} + func GetJsonSafe[T any](jsonStr string) (T, error) { var result T @@ -46,4 +91,20 @@ func GetJson[T any](jsonStr string) (T) { } return result +} + +func ToJsonSafe[T any](obj T) (string, error) { + bytes, err := json.Marshal(obj) + + return string(bytes), err +} + +func ToJson[T any](obj T) string { + bytes, err := json.Marshal(obj) + + if err != nil { + // JSON is empty + } + + return string(bytes) } \ No newline at end of file