diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md new file mode 100644 index 0000000..6d7d7a2 --- /dev/null +++ b/doc/PLUGINS.md @@ -0,0 +1,99 @@ +Plugins allow to dynamically register custom endpoints without forking this project. + +# Why? + +Imagine that you want to use the Signal REST API in a software component that has some restrictions regarding the payload it supports. To give you a real world example: If you want to use the Signal REST API to send Signal notifications from your Synology NAS to your phone, the HTTP endpoint must have only "flat" parameters - i.e array parameters in the JSON payload aren't allowed. Since the `recipients` parameter in the `/v2/send` endpoint is a array parameter, the send endpoint cannot be used in the Synology NAS. In order to work around that limitation, you can write a small custom plugin in Lua, to create a custom send endpoint, which exposes a single `recipient` string instead of an array. + +# How to write a custom plugin + +In order to use plugins, you first need to enable that feature. This can be done by setting the environment variable `ENABLE_PLUGINS` to `true` in the `docker-compose.yml` file. + +e.g: + +``` +services: + signal-cli-rest-api: + image: bbernhard/signal-cli-rest-api:latest + environment: + - MODE=json-rpc #supported modes: json-rpc, native, normal + - ENABLE_PLUGINS=true #enable plugins +``` + +A valid plugin consists of a definition file (with the file ending `.def`) and a matching lua script file (with the file ending `.lua`). Both of those files must have the same filename and are placed in a folder called `plugins` on the host filesystem. Now, bind mount the `plugins` folder from your host system into the `/plugins` folder inside the docker container. This can be done in the `docker-compose.yml` file: + +``` +services: + signal-cli-rest-api: + image: bbernhard/signal-cli-rest-api:latest + environment: + - MODE=json-rpc #supported modes: json-rpc, native, normal + - ENABLE_PLUGINS=true + volumes: + - "./signal-cli-config:/home/.local/share/signal-cli" + - "./plugins:/plugins" #map "plugins" folder on host system into docker container. +``` + +# The definition file + +The definition file (with the file suffix `.def`) contains some metadata which is necessary to properly register the new endpoint. A proper definition file looks like this: + +``` +endpoint: my-custom-send-endpoint/:number +method: POST +``` + +The `endpoint` specifies the URI of the newly created endpoint. All custom endpoints are registered under the `/v1/plugins` endpoint. So, our `my-custom-send-endpoint` will be available at `/v1/plugins/my-custom-endpoint`. If you want to use variables inside the endpoint, prefix them with a `:`. + +The `method` parameter specifies the HTTP method that is used for the endpoint registration. + +# The script file + +The script file (with the file suffix `.lua`) contains the implementation of the endpoint. + +Example: + +``` +local http = require("http") +local json = require("json") + +local url = "http://127.0.0.1:8080/v2/send" + +local customEndpointPayload = json.decode(pluginInputData.payload) + +local sendEndpointPayload = { + recipients = {customEndpointPayload.recipient}, + message = customEndpointPayload.message, + number = pluginInputData.Params.number +} + +local encodedSendEndpointPayload = json.encode(sendEndpointPayload) + +response, error_message = http.request("POST", url, { + timeout="30s", + headers={ + Accept="*/*", + ["Content-Type"]="application/json" + }, + body=encodedSendEndpointPayload +}) + +pluginOutputData:SetPayload(response["body"]) +pluginOutputData:SetHttpStatusCode(response.status_code) +``` + +What the lua script does, is parse the JSON payload from the custom request, extract the `recipient` and the `message` from the payload and the `number` from the URL parameter and call the `/v2/send` endpoint with those parameters. The HTTP status code and the body that is returned by the HTTP request is then returned to the caller (this is done via the `pluginOutputData:SetPayload` and `pluginOutputData:SetHttpStatusCode` functions. + +If you now invoke the following curl command, a message gets sent: + +`curl -X POST -H "Content-Type: application/json" -d '{"message": "test", "recipient": ""}' 'http://127.0.0.1:8080/v1/plugins/my-custom-send-endpoint/'` + +# Pass commands from/to the lua script + +When a new plugin is registered, some parameters are automatically passed as global variables to the lua script: + +* `pluginInputData.payload`: the (JSON) payload that is passed to the custom endpoint +* `pluginInputData.Params`: a map of all parameters that are part of the URL, which were defined in the definition file (i.e those parameters that were defined with `:` prefixed in the URL) + +In order to return values from the lua script, the following functions are available: +* `pluginOutputData:SetPayload()`: Set the (JSON) payload that is returned to the caller +* `pluginOutputData:SetHttpStatusCode()`: Set the HTTP status code that is returned to the caller diff --git a/plugins/example.def b/plugins/example.def new file mode 100644 index 0000000..f4b6dfe --- /dev/null +++ b/plugins/example.def @@ -0,0 +1,2 @@ +endpoint: my-custom-send-endpoint/:number +method: POST diff --git a/plugins/example.lua b/plugins/example.lua new file mode 100644 index 0000000..8a66f11 --- /dev/null +++ b/plugins/example.lua @@ -0,0 +1,27 @@ +local http = require("http") +local json = require("json") + +local url = "http://127.0.0.1:8080/v2/send" + +local customEndpointPayload = json.decode(pluginInputData.payload) + +local sendEndpointPayload = { + recipients = {customEndpointPayload.recipient}, + message = customEndpointPayload.message, + number = pluginInputData.Params.number +} + +local encodedSendEndpointPayload = json.encode(sendEndpointPayload) +print(encodedSendEndpointPayload) + +response, error_message = http.request("POST", url, { + timeout="30s", + headers={ + Accept="*/*", + ["Content-Type"]="application/json" + }, + body=encodedSendEndpointPayload +}) + +pluginOutputData:SetPayload(response["body"]) +pluginOutputData:SetHttpStatusCode(response.status_code) diff --git a/src/api/api.go b/src/api/api.go index cbda348..6914c1e 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "time" + "io" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" @@ -19,6 +20,11 @@ import ( "github.com/bbernhard/signal-cli-rest-api/client" ds "github.com/bbernhard/signal-cli-rest-api/datastructs" utils "github.com/bbernhard/signal-cli-rest-api/utils" + + "github.com/yuin/gopher-lua" + "github.com/cjoudrey/gluahttp" + "layeh.com/gopher-luar" + luajson "layeh.com/gopher-json" ) const ( @@ -2166,3 +2172,69 @@ func (a *Api) ListContacts(c *gin.Context) { c.JSON(200, contacts) } + +type PluginInputData struct { + Params map[string]string + Payload string +} + +type PluginOutputData struct { + payload string + httpStatusCode int +} + +func (p *PluginOutputData) SetPayload(payload string) { + p.payload = payload +} + +func (p *PluginOutputData) Payload() string { + return p.payload +} + +func (p *PluginOutputData) SetHttpStatusCode(httpStatusCode int) { + p.httpStatusCode = httpStatusCode +} + +func (p *PluginOutputData) HttpStatusCode() int { + return p.httpStatusCode +} + +func (a *Api) ExecutePlugin(c *gin.Context, pluginConfig utils.PluginConfig) { + jsonData, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - invalid input data"}) + log.Error(err.Error()) + return + } + + pluginInputData := &PluginInputData{ + Params: make(map[string]string), + Payload: string(jsonData), + } + + pluginOutputData := &PluginOutputData{ + payload: "", + httpStatusCode: 200, + } + + parts := strings.Split(pluginConfig.Endpoint, "/") + for _, part := range parts { + if strings.HasPrefix(part, ":") { + paramName := strings.TrimPrefix(part, ":") + pluginInputData.Params[paramName] = c.Param(paramName) + } + } + + l := lua.NewState() + l.SetGlobal("pluginInputData", luar.New(l, pluginInputData)) + l.SetGlobal("pluginOutputData", luar.New(l, pluginOutputData)) + l.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader) + luajson.Preload(l) + defer l.Close() + if err := l.DoFile(pluginConfig.ScriptPath); err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.JSON(pluginOutputData.HttpStatusCode(), pluginOutputData.Payload()) +} diff --git a/src/go.mod b/src/go.mod index 37372c3..7194751 100644 --- a/src/go.mod +++ b/src/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 // indirect github.com/cyphar/filepath-securejoin v0.2.4 github.com/gabriel-vasile/mimetype v1.4.2 github.com/gin-gonic/gin v1.9.1 @@ -22,6 +23,9 @@ require ( github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 + github.com/yuin/gopher-lua v1.1.1 // indirect golang.org/x/net v0.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 + layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf // indirect + layeh.com/gopher-luar v1.0.11 // indirect ) diff --git a/src/go.sum b/src/go.sum index 0a6f834..f8f3100 100644 --- a/src/go.sum +++ b/src/go.sum @@ -12,6 +12,11 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o= +github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= @@ -168,6 +173,9 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -219,6 +227,7 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -305,4 +314,8 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= +layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/src/main.go b/src/main.go index 37e95ff..e22e3a4 100644 --- a/src/main.go +++ b/src/main.go @@ -61,6 +61,16 @@ import ( // @host localhost:8080 // @schemes http // @BasePath / + +func PluginHandler(api *api.Api, pluginConfig utils.PluginConfig) gin.HandlerFunc { + fn := func(c *gin.Context) { + api.ExecutePlugin(c, pluginConfig) + } + + return gin.HandlerFunc(fn) +} + + func main() { signalCliConfig := flag.String("signal-cli-config", "/home/.local/share/signal-cli/", "Config directory where signal-cli config is stored") attachmentTmpDir := flag.String("attachment-tmp-dir", "/tmp/", "Attachment tmp directory") @@ -277,6 +287,29 @@ func main() { contacts.PUT(":number", api.UpdateContact) contacts.POST(":number/sync", api.SendContacts) } + + if utils.GetEnv("ENABLE_PLUGINS", "false") == "true" { + plugins := v1.Group("/plugins") + { + pluginConfigs := utils.NewPluginConfigs() + err := pluginConfigs.Load("/plugins") + if err != nil { + log.Fatal("Couldn't load plugin configs: ", err.Error()) + } + + for _, pluginConfig := range pluginConfigs.Configs { + if pluginConfig.Method == "GET" { + plugins.GET(pluginConfig.Endpoint, PluginHandler(api, pluginConfig)) + } else if pluginConfig.Method == "POST" { + plugins.POST(pluginConfig.Endpoint, PluginHandler(api, pluginConfig)) + } else if pluginConfig.Method == "DELETE" { + plugins.DELETE(pluginConfig.Endpoint, PluginHandler(api, pluginConfig)) + } else if pluginConfig.Method == "PUT" { + plugins.PUT(pluginConfig.Endpoint, PluginHandler(api, pluginConfig)) + } + } + } + } } v2 := router.Group("/v2") diff --git a/src/utils/plugin_config.go b/src/utils/plugin_config.go new file mode 100644 index 0000000..5349216 --- /dev/null +++ b/src/utils/plugin_config.go @@ -0,0 +1,54 @@ +package utils + +import ( + "gopkg.in/yaml.v2" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +type PluginConfig struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method"` + ScriptPath string +} + +func NewPluginConfigs() *PluginConfigs { + return &PluginConfigs{} +} + +type PluginConfigs struct { + Configs []PluginConfig +} + +func (c *PluginConfigs) Load(baseDirectory string) error { + + err := filepath.Walk(baseDirectory, func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + + if filepath.Ext(path) != ".def" { + return nil + } + + if _, err := os.Stat(path); err == nil { + data, err := ioutil.ReadFile(path) + if err != nil { + return err + } + + var pluginConfig PluginConfig + err = yaml.Unmarshal(data, &pluginConfig) + if err != nil { + return err + } + pluginConfig.ScriptPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".lua" + c.Configs = append(c.Configs, pluginConfig) + } + return nil + }) + + return err +}