Skip to content

Commit

Permalink
added plugin mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
bbernhard committed Jan 6, 2025
1 parent 1ce75f5 commit 3752538
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 0 deletions.
99 changes: 99 additions & 0 deletions doc/PLUGINS.md
Original file line number Diff line number Diff line change
@@ -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": "<recipient>"}' 'http://127.0.0.1:8080/v1/plugins/my-custom-send-endpoint/<registered signal number>'`

# 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
2 changes: 2 additions & 0 deletions plugins/example.def
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
endpoint: my-custom-send-endpoint/:number
method: POST
27 changes: 27 additions & 0 deletions plugins/example.lua
Original file line number Diff line number Diff line change
@@ -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)
72 changes: 72 additions & 0 deletions src/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"sync"
"time"
"io"

"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
Expand All @@ -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 (
Expand Down Expand Up @@ -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())
}
4 changes: 4 additions & 0 deletions src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
13 changes: 13 additions & 0 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
33 changes: 33 additions & 0 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
54 changes: 54 additions & 0 deletions src/utils/plugin_config.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 3752538

Please sign in to comment.