diff --git a/.github/templates/README.template.md b/.github/templates/README.template.md index d491da9..ef74679 100644 --- a/.github/templates/README.template.md +++ b/.github/templates/README.template.md @@ -31,10 +31,12 @@ endpoint restrictions, placeholders, flexible configuration - [Getting Started](#getting-started) - [Setup](#setup) - [Usage](#usage) -- [Best Practices](#security-best-practices) +- [Best Practices](#best-practices) - [Configuration](#configuration) - [Endpoints](#endpoints) - [Variables](#variables) + - [Data Aliases](#data-aliases) + - [Message Templates](#message-templates) - [Contributing](#contributing) - [Support](#support) - [License](#license) @@ -119,31 +121,24 @@ curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer API_T #### Placeholders -If you are not comfortable / don't want to hardcode your Number for example and/or Recipients in you, may use **Placeholders** in your Request. See [Custom Variables](#variables). +If you are not comfortable / don't want to hardcode your Number for example and/or Recipients in you, may use **Placeholders** in your Request. -These Placeholders can be used in the Request Query or the Body of a Request like so: +You can use [**Variable**](#variables) `{{.NUMBER}}` Placeholders and **Body** Placeholders `{{@data.key}}`. -**Body** +| Type | Example | +| :---- | :--------------------------------------------------------------- | +| Body | `{"number": "{{ .NUMBER }}", "recipients": "{{ .RECIPIENTS }}"}` | +| Query | `http://sec-signal-api:8880/v1/receive/?@number={{.NUMBER}}` | +| Path | `http://sec-signal-api:8880/v1/receive/{{.NUMBER}}` | + +You can also combine them: ```json { - "number": "{{ .NUMBER }}", - "recipients": "{{ .RECIPIENTS }}" + "content": "{{.NUMBER}} -> {{.RECIPIENTS}}" } ``` -**Query** - -``` -http://sec-signal-api:8880/v1/receive/?@number={{.NUMBER}} -``` - -**Path** - -``` -http://sec-signal-api:8880/v1/receive/{{.NUMBER}} -``` - #### KeyValue Pair Injection In some cases you may not be able to access / modify the Request Body, in that case specify needed values in the Request Query: @@ -153,7 +148,7 @@ In some cases you may not be able to access / modify the Request Body, in that c In order to differentiate Injection Queries and _regular_ Queries you have to add `@` in front of any KeyValue Pair assignment. -Supported types include **strings**, **ints** and **arrays**. See [Formatting](#string-to-type). +Supported types include **strings**, **ints**, **arrays** and **json dictionaries**. See [Formatting](#string-to-type). ## Best Practices @@ -293,12 +288,27 @@ settings: recipients: ["+123400002", "group.id", "user.id"] ``` -### Message Aliases +### Message Templates -To improve compatibility with other services Secured Signal API provides **Message Aliases** for the `message` attribute. +To customize the `message` attribute you can use **Message Templates** to build your message by using other Body Keys and Variables. +Use `messageTemplate` to configure: + +```yaml +settings: + messageTemplate: | + Your Message: + {{@message}}. + Sent with Secured Signal API. +``` + +Use `{{@data.key}}` to reference Body Keys and `{{.KEY}}` for Variables. + +### Data Aliases + +To improve compatibility with other services Secured Signal API provides **Data Aliases** and a built-in `message` Alias.
-Default Message Aliases +Default `message` Aliases | Alias | Score | Alias | Score | | ------------ | ----- | ---------------- | ----- | @@ -312,23 +322,27 @@ To improve compatibility with other services Secured Signal API provides **Messa
-Secured Signal API will pick the best scoring Message Alias (if available) to extract the correct message from the Request Body. +Secured Signal API will pick the best scoring Data Alias (if available) to extract set the Key to the correct Value from the Request Body. -Message Aliases can be added by setting `messageAliases` in your config: +Data Aliases can be added by setting `dataAliases` in your config: ```yaml settings: - messageAliases: - [ - { alias: "msg", score: 80 }, - { alias: "data.message", score: 79 }, - { alias: "array[0].message", score: 78 }, - ] + dataAliases: + "@message": + [ + { alias: "msg", score: 80 }, + { alias: "data.message", score: 79 }, + { alias: "array[0].message", score: 78 }, + ] + ".NUMBER": [{ alias: "phone_number", score: 100 }] ``` +Use `@` for aliasing Body Keys and `.` for aliasing Variables. + ### Port -To change the Port which Secured Signal API uses, you need to set `server.port` in your config. (default: `8880`) +To change the Port which Secured Signal API uses, you need to set `service.port` in your config. (default: `8880`) ### Log Level diff --git a/README.md b/README.md index 5575000..5337f8c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ endpoint restrictions, placeholders, flexible configuration - [Configuration](#configuration) - [Endpoints](#endpoints) - [Variables](#variables) + - [Message Templates](#message-templates) - [Contributing](#contributing) - [Support](#support) - [License](#license) @@ -65,10 +66,9 @@ services: container_name: secured-signal environment: API__URL: http://signal-api:8080 - SETTINGS__VARIABLES__RECIPIENTS: - '[+123400002, +123400003, +123400004]' + SETTINGS__VARIABLES__RECIPIENTS: "[+123400002, +123400003, +123400004]" SETTINGS__VARIABLES__NUMBER: "+123400001" - API__TOKENS: '[LOOOOOONG_STRING]' + API__TOKENS: "[LOOOOOONG_STRING]" ports: - "8880:8880" restart: unless-stopped @@ -100,10 +100,9 @@ services: container_name: secured-signal environment: API__URL: http://signal-api:8080 - SETTINGS__VARIABLES__RECIPIENTS: - '[+123400002,+123400003,+123400004]' + SETTINGS__VARIABLES__RECIPIENTS: "[+123400002,+123400003,+123400004]" SETTINGS__VARIABLES__NUMBER: "+123400001" - API__TOKENS: '[LOOOOOONG_STRING]' + API__TOKENS: "[LOOOOOONG_STRING]" labels: - traefik.enable=true - traefik.http.routers.signal-api.rule=Host(`signal-api.mydomain.com`) @@ -438,39 +437,6 @@ settings: recipients: ["+123400002", "group.id", "user.id"] ``` -### Message Aliases - -To improve compatibility with other services Secured Signal API provides **Message Aliases** for the `message` attribute. - -
-Default Message Aliases - -| Alias | Score | Alias | Score | -| ------------ | ----- | ---------------- | ----- | -| msg | 100 | data.content | 9 | -| content | 99 | data.description | 8 | -| description | 98 | data.text | 7 | -| text | 20 | data.summary | 6 | -| summary | 15 | data.details | 5 | -| details | 14 | body | 2 | -| data.message | 10 | 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 `messageAliases` in your config: - -```yaml -settings: - messageAliases: - [ - { alias: "msg", score: 80 }, - { alias: "data.message", score: 79 }, - { alias: "array[0].message", score: 78 }, - ] -``` - ### Port To change the Port which Secured Signal API uses, you need to set `server.port` in your config. (default: `8880`) diff --git a/data/defaults.yml b/data/defaults.yml index 82ec91c..229832b 100644 --- a/data/defaults.yml +++ b/data/defaults.yml @@ -4,25 +4,26 @@ service: logLevel: info settings: - messageAliases: - [ - { alias: msg, score: 100 }, - { alias: content, score: 99 }, - { alias: description, score: 98 }, - { alias: text, score: 20 }, - { alias: summary, score: 15 }, - { alias: details, score: 14 }, + dataAliases: + "@message": + [ + { alias: msg, score: 100 }, + { alias: content, score: 99 }, + { alias: description, score: 98 }, + { alias: text, score: 20 }, + { alias: summary, score: 15 }, + { alias: details, score: 14 }, - { alias: data.message, score: 10 }, - { alias: data.content, score: 9 }, - { alias: data.description, score: 8 }, - { alias: data.text, score: 7 }, - { alias: data.summary, score: 6 }, - { alias: data.details, score: 5 }, + { alias: data.message, score: 10 }, + { alias: data.content, score: 9 }, + { alias: data.description, score: 8 }, + { alias: data.text, score: 7 }, + { alias: data.summary, score: 6 }, + { alias: data.details, score: 5 }, - { alias: body, score: 2 }, - { alias: data, score: 1 }, - ] + { alias: body, score: 2 }, + { alias: data, score: 1 }, + ] variables: recipients: ${RECIPIENTS} diff --git a/examples/config.yml b/examples/config.yml index 9be5a31..2005892 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -1,18 +1,26 @@ # Example Config (all configurations shown) +service: + port: 8880 api: - port: 8880 url: http://signal-api:8080 tokens: [token1, token2] -logLevel: INFO +logLevel: info settings: + messageTemplate: | + You've got a Notification: + {{@message}} + At {{@data.timestamp}} on {{@data.date}}. + Send using {{.NUMBER}}. + variables: number: "+123400001" recipients: ["+123400002", "group.id", "user.id"] - messageAliases: [{ alias: "msg", score: 100 }] + dataAliases: + "@message": [{ alias: "msg", score: 100 }] blockedEndpoints: - /v1/about diff --git a/internals/proxy/middlewares/body.go b/internals/proxy/middlewares/aliases.go similarity index 52% rename from internals/proxy/middlewares/body.go rename to internals/proxy/middlewares/aliases.go index 25f01fe..d38e81a 100644 --- a/internals/proxy/middlewares/body.go +++ b/internals/proxy/middlewares/aliases.go @@ -12,18 +12,24 @@ import ( request "github.com/codeshelldev/secured-signal-api/utils/request" ) -type BodyMiddleware struct { +type AliasMiddleware struct { Next http.Handler } -func (data BodyMiddleware) Use() http.Handler { +func (data AliasMiddleware) Use() http.Handler { next := data.Next return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - messageAliases := getSettingsByReq(req).MESSAGE_ALIASES + settings := getSettingsByReq(req) - if messageAliases == nil { - messageAliases = getSettings("*").MESSAGE_ALIASES + dataAliases := settings.DATA_ALIASES + + if dataAliases == nil { + dataAliases = getSettings("*").DATA_ALIASES + } + + if settings.VARIABLES == nil { + settings.VARIABLES = getSettings("*").VARIABLES } body, err := request.GetReqBody(w, req) @@ -38,13 +44,20 @@ func (data BodyMiddleware) Use() http.Handler { if !body.Empty { bodyData = body.Data - content, ok := bodyData["message"] + aliasData := processDataAliases(dataAliases, bodyData) - if !ok || content == "" { + for key, value := range aliasData { + prefix := key[:1] - bodyData["message"], bodyData = getMessage(messageAliases, bodyData) + keyWithoutPrefix := key[1:] - modifiedBody = true + switch prefix { + case "@": + bodyData[keyWithoutPrefix] = value + modifiedBody = true + case ".": + settings.VARIABLES[keyWithoutPrefix] = value + } } } @@ -70,32 +83,44 @@ func (data BodyMiddleware) Use() http.Handler { }) } -func getMessage(aliases []middlewareTypes.MessageAlias, data map[string]any) (string, map[string]any) { - var content string +func processDataAliases(aliases map[string][]middlewareTypes.DataAlias, data map[string]any) (map[string]any) { + aliasData := map[string]any{} + + for key, alias := range aliases { + key, value := getData(key, alias, data) + + aliasData[key] = value + } + + return aliasData +} + +func getData(key string, aliases []middlewareTypes.DataAlias, data map[string]any) (string, any) { var best int + var value any for _, alias := range aliases { aliasValue, score, ok := processAlias(alias, data) - if ok && score > best { - content = aliasValue - } + if ok { + if score > best { + value = aliasValue + } - data[alias.Alias] = nil + data[alias.Alias] = nil + } } - return content, data + return key, value } -func processAlias(alias middlewareTypes.MessageAlias, data map[string]any) (string, int, bool) { +func processAlias(alias middlewareTypes.DataAlias, data map[string]any) (any, int, bool) { aliasKey := alias.Alias value, ok := jsonutils.GetByPath(aliasKey, data) - aliasValue, isStr := value.(string) - - if isStr && ok && aliasValue != "" { - return aliasValue, alias.Score, true + if ok && value != nil { + return value, alias.Score, true } else { return "", 0, false } diff --git a/internals/proxy/middlewares/message.go b/internals/proxy/middlewares/message.go new file mode 100644 index 0000000..5761a7a --- /dev/null +++ b/internals/proxy/middlewares/message.go @@ -0,0 +1,94 @@ +package middlewares + +import ( + "bytes" + "io" + "net/http" + "strconv" + + log "github.com/codeshelldev/secured-signal-api/utils/logger" + request "github.com/codeshelldev/secured-signal-api/utils/request" +) + +type MessageMiddleware struct { + Next http.Handler +} + +func (data MessageMiddleware) Use() http.Handler { + next := data.Next + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + settings := getSettingsByReq(req) + + variables := settings.VARIABLES + messageTemplate := settings.MESSAGE_TEMPLATE + + if variables == nil { + variables = getSettings("*").VARIABLES + } + + if messageTemplate == "" { + messageTemplate = getSettings("*").MESSAGE_TEMPLATE + } + + + body, err := request.GetReqBody(w, req) + + if err != nil { + log.Error("Could not get Request Body: ", err.Error()) + } + + bodyData := map[string]any{} + + var modifiedBody bool + + if !body.Empty { + bodyData = body.Data + + newData, err := TemplateMessage(messageTemplate, bodyData, variables) + + if err != nil { + log.Error("Error Templating Message: ", err.Error()) + } + + if newData["message"] != bodyData["message"] && newData["message"] != "" { + bodyData = newData + modifiedBody = true + } + } + + if modifiedBody { + modifiedBody, err := request.CreateBody(bodyData) + + if err != nil { + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + body = modifiedBody + + strData := body.ToString() + + log.Debug("Applied Message Templating: ", strData) + + req.ContentLength = int64(len(strData)) + req.Header.Set("Content-Length", strconv.Itoa(len(strData))) + } + + req.Body = io.NopCloser(bytes.NewReader(body.Raw)) + + next.ServeHTTP(w, req) + }) +} + +func TemplateMessage(template string, data map[string]any, VARIABLES map[string]any) (map[string]any, error) { + data["message"] = template + + data, ok, err := TemplateBody(data, VARIABLES) + + if err != nil || !ok || data == nil { + return data, err + } + + return data, nil +} \ No newline at end of file diff --git a/internals/proxy/middlewares/template.go b/internals/proxy/middlewares/template.go index 25946ba..4365185 100644 --- a/internals/proxy/middlewares/template.go +++ b/internals/proxy/middlewares/template.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" jsonutils "github.com/codeshelldev/secured-signal-api/utils/jsonutils" @@ -104,10 +105,28 @@ func (data TemplateMiddleware) Use() http.Handler { }) } -func TemplateBody(data map[string]any, VARIABLES any) (map[string]any, bool, error) { +func TemplateBody(data map[string]any, VARIABLES map[string]any) (map[string]any, bool, error) { var modified bool - templatedData, err := templating.RenderJSONTemplate("body", data, VARIABLES) + jsonStr := jsonutils.ToJson(data) + + if jsonStr != "" { + re, err := regexp.Compile(`{{\s*\@([a-zA-Z0-9_.]+)\s*}}`) + + if err != nil { + return data, false, err + } + + jsonStr = re.ReplaceAllString(jsonStr, "{{.$1}}") + + normalizedData, err := jsonutils.GetJsonSafe[map[string]any](jsonStr) + + if err == nil { + data = normalizedData + } + } + + templatedData, err := templating.RenderJSON("body", data, VARIABLES) if err != nil { return data, false, err diff --git a/internals/proxy/middlewares/types/types.go b/internals/proxy/middlewares/types/types.go index ed96549..359f49b 100644 --- a/internals/proxy/middlewares/types/types.go +++ b/internals/proxy/middlewares/types/types.go @@ -3,7 +3,7 @@ package middlewareTypes -type MessageAlias struct { +type DataAlias struct { Alias string `koanf:"alias"` Score int `koanf:"score"` } \ No newline at end of file diff --git a/main.go b/main.go index 4cf1eff..bd1fe44 100644 --- a/main.go +++ b/main.go @@ -33,12 +33,16 @@ func main() { proxy_last = proxy.Create(ENV.API_URL) - body_m5 := middlewares.BodyMiddleware{ + mesg_m6 := middlewares.MessageMiddleware{ Next: proxy_last, } + alias_m5 := middlewares.AliasMiddleware{ + Next: mesg_m6.Use(), + } + temp_m4 := middlewares.TemplateMiddleware{ - Next: body_m5.Use(), + Next: alias_m5.Use(), } endp_m3 := middlewares.EndpointsMiddleware{ @@ -79,4 +83,6 @@ func main() { <-stop docker.Shutdown(server) -} \ No newline at end of file +} + +// TESTING \ No newline at end of file diff --git a/tests/json_test.go b/tests/json_test.go index 399ba48..87f10bb 100644 --- a/tests/json_test.go +++ b/tests/json_test.go @@ -19,6 +19,7 @@ func TestJsonTemplating(t *testing.T) { json := ` { + "multiple": "{{.key}}, {{.int}}", "dict": { "key": "{{.key}}" }, "dictArray": [ { "key": "{{.key}}" }, @@ -31,6 +32,7 @@ func TestJsonTemplating(t *testing.T) { data := jsonutils.GetJson[map[string]any](json) expected := map[string]any{ + "multiple": "val, 4", "dict": map[string]any{ "key": "val", }, diff --git a/utils/config/loader.go b/utils/config/loader.go index ea0b746..a226a6c 100644 --- a/utils/config/loader.go +++ b/utils/config/loader.go @@ -28,10 +28,11 @@ type ENV_ struct { } type SETTING_ struct { - BLOCKED_ENDPOINTS []string `koanf:"blockedendpoints"` - ALLOWED_ENDPOINTS []string `koanf:"allowedendpoints"` - VARIABLES map[string]any `koanf:"variables"` - MESSAGE_ALIASES []middlewareTypes.MessageAlias `koanf:"messagealiases"` + BLOCKED_ENDPOINTS []string `koanf:"blockedendpoints"` + ALLOWED_ENDPOINTS []string `koanf:"allowedendpoints"` + VARIABLES map[string]any `koanf:"variables"` + DATA_ALIASES map[string][]middlewareTypes.DataAlias `koanf:"dataaliases"` + MESSAGE_TEMPLATE string `koanf:"messagetemplate"` } var ENV *ENV_ = &ENV_{ @@ -79,14 +80,10 @@ func InitEnv() { var settings SETTING_ - transformChildren(config, "settings.variables", func(key string, value any) (string, any) { - return strings.ToUpper(key), value - }) + transformChildren(config, "settings.variables", transformVariables) config.Unmarshal("settings", &settings) - log.Dev(jsonutils.ToJson(settings)) - ENV.SETTINGS["*"] = &settings } @@ -109,3 +106,7 @@ func LoadConfig() { } } } + +func transformVariables(key string, value any) (string, any) { + return strings.ToUpper(key), value +} diff --git a/utils/config/tokens.go b/utils/config/tokens.go index ce995a0..b271579 100644 --- a/utils/config/tokens.go +++ b/utils/config/tokens.go @@ -2,7 +2,6 @@ package config import ( "strconv" - "strings" log "github.com/codeshelldev/secured-signal-api/utils/logger" "github.com/knadh/koanf/parsers/yaml" @@ -28,9 +27,7 @@ func InitTokens() { var tokenConfigs []TOKEN_CONFIG_ - transformChildrenUnderArray(tokensLayer, "tokenconfigs", "overrides.variables", func(key string, value any) (string, any) { - return strings.ToUpper(key), value - }) + transformChildrenUnderArray(tokensLayer, "tokenconfigs", "overrides.variables", transformVariables) tokensLayer.Unmarshal("tokenconfigs", &tokenConfigs) diff --git a/utils/templating/templating.go b/utils/templating/templating.go index 37d4ec3..3f66c90 100644 --- a/utils/templating/templating.go +++ b/utils/templating/templating.go @@ -13,7 +13,6 @@ func normalize(value any) string { switch str := value.(type) { case []string: return "[" + strings.Join(str, ",") + "]" - case []any: items := make([]string, len(str)) @@ -36,6 +35,10 @@ func normalizeJSON(value any) string { case []any, []string, map[string]any, int, float64, bool: object, _ := json.Marshal(value) + if string(object) == "{}" { + return value.(string) + } + return "<<" + string(object) + ">>" default: @@ -43,6 +46,37 @@ func normalizeJSON(value any) string { } } +func cleanQuotedPairsJSON(s string) string { + quoteRe, err := regexp.Compile(`"([^"]*?)"`) + + if err != nil { + return s + } + + pairRe, err := regexp.Compile(`<<([^<>]+)>>`) + + if err != nil { + return s + } + + return quoteRe.ReplaceAllStringFunc(s, func(container string) string { + inner := container[1 : len(container)-1] // remove quotes + + matches := pairRe.FindAllStringSubmatchIndex(inner, -1) + + // ONE pair which fills whole "" + if len(matches) == 1 && matches[0][0] == 0 && matches[0][1] == len(inner) { + return container // keep <<...>> untouched + } + + // MULTIPLE pairs || that do not fill whole "" + inner = pairRe.ReplaceAllString(inner, "$1") + inner = strings.ReplaceAll(inner, `"`, `'`) + + return `"` + inner + `"` + }) +} + func ParseTemplate(templt *template.Template, tmplStr string, variables any) (string, error) { tmpl, err := templt.Parse(tmplStr) @@ -69,7 +103,23 @@ func CreateTemplateWithFunc(name string, funcMap template.FuncMap) (*template.Te return template.New(name).Funcs(funcMap) } -func RenderJSONTemplate(name string, data map[string]any, variables any) (map[string]any, error) { +func RenderJSON(name string, data map[string]any, variables map[string]any) (map[string]any, error) { + combinedData := data + + for key, value := range variables { + combinedData[key] = value + } + + data, err := RenderJSONTemplate(name, data, combinedData) + + if err != nil { + return data, err + } + + return data, nil +} + +func RenderJSONTemplate(name string, data map[string]any, variables map[string]any) (map[string]any, error) { jsonBytes, err := json.Marshal(data) if err != nil { @@ -78,7 +128,7 @@ func RenderJSONTemplate(name string, data map[string]any, variables any) (map[st tmplStr := string(jsonBytes) - re, err := regexp.Compile(`{{\s*\.(\w+)\s*}}`) + re, err := regexp.Compile(`{{\s*\.([a-zA-Z0-9_.]+)\s*}}`) // Add normalize() to be able to remove Quotes from Arrays if err == nil { @@ -95,6 +145,8 @@ func RenderJSONTemplate(name string, data map[string]any, variables any) (map[st return nil, err } + jsonStr = cleanQuotedPairsJSON(jsonStr) + // Remove the Quotes around "<<[item1,item2]>>" re, err = regexp.Compile(`"<<(.*?)>>"`)