Merge pull request #225 from binwiederhier/actions-parsing
WIP: More advanced action parsing
This commit is contained in:
commit
fb56ab9a06
8 changed files with 519 additions and 189 deletions
|
@ -850,27 +850,32 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
|
||||||
<action1>, <label1>, paramN=... [; <action2>, <label2>, ...]
|
<action1>, <label1>, paramN=... [; <action2>, <label2>, ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and `http` action.
|
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
|
||||||
The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8
|
quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
|
||||||
characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations.
|
|
||||||
|
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and
|
||||||
|
`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not
|
||||||
|
work. If they don't, use the [JSON array format](#using-a-json-array) instead.
|
||||||
|
|
||||||
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
||||||
[`http` action](#send-http-request) section for details on the specific actions:
|
[`http` action](#send-http-request) section for details on the specific actions:
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
|
body='{"temperature": 65}'
|
||||||
curl \
|
curl \
|
||||||
-d "You left the house. Turn down the A/C?" \
|
-d "You left the house. Turn down the A/C?" \
|
||||||
-H "Actions: view, Open portal, https://home.nest.com/, clear=true; \
|
-H "Actions: view, Open portal, https://home.nest.com/, clear=true; \
|
||||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||||
ntfy.sh/myhome
|
ntfy.sh/myhome
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "ntfy CLI"
|
=== "ntfy CLI"
|
||||||
```
|
```
|
||||||
|
body='{"temperature": 65}'
|
||||||
ntfy publish \
|
ntfy publish \
|
||||||
--actions="view, Open portal, https://home.nest.com/, clear=true; \
|
--actions="view, Open portal, https://home.nest.com/, clear=true; \
|
||||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||||
myhome \
|
myhome \
|
||||||
"You left the house. Turn down the A/C?"
|
"You left the house. Turn down the A/C?"
|
||||||
```
|
```
|
||||||
|
@ -879,7 +884,7 @@ As an example, here's how you can create the above notification using this forma
|
||||||
``` http
|
``` http
|
||||||
POST /myhome HTTP/1.1
|
POST /myhome HTTP/1.1
|
||||||
Host: ntfy.sh
|
Host: ntfy.sh
|
||||||
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65
|
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{"temperature": 65}'
|
||||||
|
|
||||||
You left the house. Turn down the A/C?
|
You left the house. Turn down the A/C?
|
||||||
```
|
```
|
||||||
|
@ -890,7 +895,7 @@ As an example, here's how you can create the above notification using this forma
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'You left the house. Turn down the A/C?',
|
body: 'You left the house. Turn down the A/C?',
|
||||||
headers: {
|
headers: {
|
||||||
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65'
|
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\'{"temperature": 65}\''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
@ -898,14 +903,14 @@ As an example, here's how you can create the above notification using this forma
|
||||||
=== "Go"
|
=== "Go"
|
||||||
``` go
|
``` go
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?"))
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?"))
|
||||||
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65")
|
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'")
|
||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh/myhome"
|
$uri = "https://ntfy.sh/myhome"
|
||||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" }
|
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }
|
||||||
$body = "You left the house. Turn down the A/C?"
|
$body = "You left the house. Turn down the A/C?"
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
@ -914,7 +919,7 @@ As an example, here's how you can create the above notification using this forma
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/myhome",
|
requests.post("https://ntfy.sh/myhome",
|
||||||
data="You left the house. Turn down the A/C?",
|
data="You left the house. Turn down the A/C?",
|
||||||
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" })
|
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" })
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PHP"
|
=== "PHP"
|
||||||
|
@ -924,7 +929,7 @@ As an example, here's how you can create the above notification using this forma
|
||||||
'method' => 'POST',
|
'method' => 'POST',
|
||||||
'header' =>
|
'header' =>
|
||||||
"Content-Type: text/plain\r\n" .
|
"Content-Type: text/plain\r\n" .
|
||||||
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
|
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'",
|
||||||
'content' => 'You left the house. Turn down the A/C?'
|
'content' => 'You left the house. Turn down the A/C?'
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
|
@ -950,8 +955,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
|
@ -970,8 +975,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]' \
|
]' \
|
||||||
myhome \
|
myhome \
|
||||||
|
@ -996,8 +1001,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1020,8 +1025,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
action: "http",
|
action: "http",
|
||||||
label: "Turn down",
|
label: "Turn down",
|
||||||
url: "https://api.nest.com/device/XZ1D2",
|
url: "https://api.nest.com/",
|
||||||
body: "target_temp_f=65"
|
body: "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -1046,8 +1051,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
|
@ -1071,8 +1076,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
@{
|
@{
|
||||||
"action"="http",
|
"action"="http",
|
||||||
"label"="Turn down"
|
"label"="Turn down"
|
||||||
"url"="https://api.nest.com/device/XZ1D2"
|
"url"="https://api.nest.com/"
|
||||||
"body"="target_temp_f=65"
|
"body"="{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
@ -1095,8 +1100,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -1122,11 +1127,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||||
[
|
[
|
||||||
"action": "http",
|
"action": "http",
|
||||||
"label": "Turn down",
|
"label": "Turn down",
|
||||||
"url": "https://api.nest.com/device/XZ1D2",
|
"url": "https://api.nest.com/",
|
||||||
"headers": [
|
"headers": [
|
||||||
"Authorization": "Bearer ..."
|
"Authorization": "Bearer ..."
|
||||||
],
|
],
|
||||||
"body": "target_temp_f=65"
|
"body": "{\"temperature\": 65}"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
])
|
])
|
||||||
|
|
|
@ -2,6 +2,22 @@
|
||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
## ntfy Android app v1.13.0 (UNRELEASED)
|
||||||
|
|
||||||
|
Bugs:
|
||||||
|
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224),
|
||||||
|
thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
|
|
||||||
|
## ntfy server v1.22.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Better parsing of the user actions, allowing quotes (no ticket)
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
## ntfy Android app v1.12.0
|
## ntfy Android app v1.12.0
|
||||||
Released Apr 25, 2022
|
Released Apr 25, 2022
|
||||||
|
|
||||||
|
|
307
server/actions.go
Normal file
307
server/actions.go
Normal file
|
@ -0,0 +1,307 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
actionIDLength = 10
|
||||||
|
actionEOF = rune(0)
|
||||||
|
actionsMax = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
actionView = "view"
|
||||||
|
actionBroadcast = "broadcast"
|
||||||
|
actionHTTP = "http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||||
|
actionsWithURL = []string{actionView, actionHTTP}
|
||||||
|
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionParser struct {
|
||||||
|
input string
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||||
|
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||||
|
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||||
|
func parseActions(s string) (actions []*action, err error) {
|
||||||
|
// Parse JSON or simple format
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "[") {
|
||||||
|
actions, err = parseActionsFromJSON(s)
|
||||||
|
} else {
|
||||||
|
actions, err = parseActionsFromSimple(s)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ID field, ensure correct uppercase/lowercase
|
||||||
|
for i := range actions {
|
||||||
|
actions[i].ID = util.RandomString(actionIDLength)
|
||||||
|
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||||
|
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if len(actions) > actionsMax {
|
||||||
|
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
||||||
|
}
|
||||||
|
for _, action := range actions {
|
||||||
|
if !util.InStringList(actionsAll, action.Action) {
|
||||||
|
return nil, fmt.Errorf("action '%s' unknown", action.Action)
|
||||||
|
} else if action.Label == "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'label' is required")
|
||||||
|
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||||
|
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||||
|
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActionsFromSimple parses the "simple" actions string (as described in
|
||||||
|
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
||||||
|
//
|
||||||
|
// It can parse an actions string like this:
|
||||||
|
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
||||||
|
//
|
||||||
|
// It works by advancing the position ("pos") through the input string ("input").
|
||||||
|
//
|
||||||
|
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
|
||||||
|
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
|
||||||
|
// though it does not use state functions at all.
|
||||||
|
//
|
||||||
|
// Other resources:
|
||||||
|
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
||||||
|
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||||
|
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||||
|
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||||
|
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||||
|
if !utf8.ValidString(s) {
|
||||||
|
return nil, errors.New("invalid string")
|
||||||
|
}
|
||||||
|
parser := &actionParser{
|
||||||
|
pos: 0,
|
||||||
|
input: s,
|
||||||
|
}
|
||||||
|
return parser.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse loops trough parseAction() until the end of the string is reached
|
||||||
|
func (p *actionParser) Parse() ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
for !p.eof() {
|
||||||
|
a, err := p.parseAction()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
actions = append(actions, a)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||||
|
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||||
|
// when EOF or ";" is reached.
|
||||||
|
func (p *actionParser) parseAction() (*action, error) {
|
||||||
|
a := newAction()
|
||||||
|
section := 0
|
||||||
|
for {
|
||||||
|
key, value, last, err := p.parseSection()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := populateAction(a, section, key, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.slurpSpaces()
|
||||||
|
if last {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
section++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||||
|
// pair to the action instance.
|
||||||
|
func populateAction(newAction *action, section int, key, value string) error {
|
||||||
|
// Auto-expand keys based on their index
|
||||||
|
if key == "" && section == 0 {
|
||||||
|
key = "action"
|
||||||
|
} else if key == "" && section == 1 {
|
||||||
|
key = "label"
|
||||||
|
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
|
||||||
|
key = "url"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("term '%s' unknown", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate
|
||||||
|
if strings.HasPrefix(key, "headers.") {
|
||||||
|
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||||
|
} else if strings.HasPrefix(key, "extras.") {
|
||||||
|
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||||
|
} else {
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "action":
|
||||||
|
newAction.Action = value
|
||||||
|
case "label":
|
||||||
|
newAction.Label = value
|
||||||
|
case "clear":
|
||||||
|
lvalue := strings.ToLower(value)
|
||||||
|
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||||
|
return fmt.Errorf("'clear=%s' not allowed", value)
|
||||||
|
}
|
||||||
|
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||||
|
case "url":
|
||||||
|
newAction.URL = value
|
||||||
|
case "method":
|
||||||
|
newAction.Method = value
|
||||||
|
case "body":
|
||||||
|
newAction.Body = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("key '%s' unknown", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
|
||||||
|
// when EOF or "," is reached.
|
||||||
|
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
|
||||||
|
p.slurpSpaces()
|
||||||
|
key = p.parseKey()
|
||||||
|
r, w := p.peek()
|
||||||
|
if isSectionEnd(r) {
|
||||||
|
p.pos += w
|
||||||
|
last = isLastSection(r)
|
||||||
|
return
|
||||||
|
} else if r == '"' || r == '\'' {
|
||||||
|
value, last, err = p.parseQuotedValue(r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, last = p.parseValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
|
||||||
|
// and returns the key if it is, or an empty string otherwise.
|
||||||
|
func (p *actionParser) parseKey() string {
|
||||||
|
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
|
||||||
|
if len(matches) == 2 {
|
||||||
|
p.pos += len(matches[0])
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
|
||||||
|
// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
|
||||||
|
// string are trimmed.
|
||||||
|
func (p *actionParser) parseValue() (value string, last bool) {
|
||||||
|
start := p.pos
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if isSectionEnd(r) {
|
||||||
|
last = isLastSection(r)
|
||||||
|
value = strings.TrimSpace(p.input[start:p.pos])
|
||||||
|
p.pos += w
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
|
||||||
|
// advances the position beyond the section end. It supports quoting strings using backslash (\).
|
||||||
|
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
|
||||||
|
p.pos++
|
||||||
|
start := p.pos
|
||||||
|
var prev rune
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if r == actionEOF {
|
||||||
|
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
|
||||||
|
return
|
||||||
|
} else if r == quote && prev != '\\' {
|
||||||
|
value = p.input[start:p.pos]
|
||||||
|
p.pos += w
|
||||||
|
|
||||||
|
// Advance until section end (after "," or ";")
|
||||||
|
p.slurpSpaces()
|
||||||
|
r, w := p.peek()
|
||||||
|
last = isLastSection(r)
|
||||||
|
if !isSectionEnd(r) {
|
||||||
|
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev = r
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slurpSpaces reads all space characters and advances the position
|
||||||
|
func (p *actionParser) slurpSpaces() {
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if r == actionEOF || !isSpace(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// peek returns the next run and its width
|
||||||
|
func (p *actionParser) peek() (rune, int) {
|
||||||
|
if p.eof() {
|
||||||
|
return actionEOF, 0
|
||||||
|
}
|
||||||
|
return utf8.DecodeRuneInString(p.input[p.pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// eof returns true if the end of the input has been reached
|
||||||
|
func (p *actionParser) eof() bool {
|
||||||
|
return p.pos >= len(p.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpace(r rune) bool {
|
||||||
|
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSectionEnd(r rune) bool {
|
||||||
|
return r == actionEOF || r == ';' || r == ','
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLastSection(r rune) bool {
|
||||||
|
return r == actionEOF || r == ';'
|
||||||
|
}
|
155
server/actions_test.go
Normal file
155
server/actions_test.go
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseActions(t *testing.T) {
|
||||||
|
actions, err := parseActions("[]")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, actions)
|
||||||
|
|
||||||
|
// Basic test
|
||||||
|
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||||
|
|
||||||
|
// Other params
|
||||||
|
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "PUT", actions[0].Method)
|
||||||
|
require.Equal(t, "this is a body", actions[0].Body)
|
||||||
|
|
||||||
|
// Extras with underscores
|
||||||
|
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "broadcast", actions[0].Action)
|
||||||
|
require.Equal(t, "Do a thing", actions[0].Label)
|
||||||
|
require.Equal(t, 2, len(actions[0].Extras))
|
||||||
|
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||||
|
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||||
|
|
||||||
|
// Headers with dashes
|
||||||
|
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Send request", actions[0].Label)
|
||||||
|
require.Equal(t, 2, len(actions[0].Headers))
|
||||||
|
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||||
|
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Single quotes
|
||||||
|
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `"quotes" and \'single quotes\'`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Single quotes (JSON)
|
||||||
|
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Post it", actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||||
|
|
||||||
|
// Out of order
|
||||||
|
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Out of order!`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Spaces
|
||||||
|
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `this is a label`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Non-ASCII
|
||||||
|
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Multiple actions, awkward spacing
|
||||||
|
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
|
||||||
|
require.Equal(t, `https://ntfy.sh`, actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, " yo ", actions[1].Label)
|
||||||
|
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||||
|
|
||||||
|
// Invalid syntax
|
||||||
|
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||||
|
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||||
|
|
||||||
|
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||||
|
require.EqualError(t, err, "parameter 'label' is required")
|
||||||
|
|
||||||
|
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||||
|
require.EqualError(t, err, "parameter 'label' is required")
|
||||||
|
|
||||||
|
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||||
|
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`fdsfdsf`)
|
||||||
|
require.EqualError(t, err, "action 'fdsfdsf' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||||
|
require.EqualError(t, err, "key 'aaa' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||||
|
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||||
|
|
||||||
|
_, err = parseActions(`;;;;`)
|
||||||
|
require.EqualError(t, err, "only 3 actions allowed")
|
||||||
|
|
||||||
|
_, err = parseActions(`,,,,,,;;`)
|
||||||
|
require.EqualError(t, err, "term '' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`''";,;"`)
|
||||||
|
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||||
|
}
|
|
@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, err = parseActions(actionsStr)
|
m.Actions, err = parseActions(actionsStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
|
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
|
|
|
@ -56,6 +56,13 @@ type action struct {
|
||||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newAction() *action {
|
||||||
|
return &action{
|
||||||
|
Headers: make(map[string]string),
|
||||||
|
Extras: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// publishMessage is used as input when publishing as JSON
|
// publishMessage is used as input when publishing as JSON
|
||||||
type publishMessage struct {
|
type publishMessage struct {
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
|
|
107
server/util.go
107
server/util.go
|
@ -1,17 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
actionIDLength = 10
|
|
||||||
actionsMax = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
value := strings.ToLower(readParam(r, names...))
|
value := strings.ToLower(readParam(r, names...))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseActions(s string) (actions []*action, err error) {
|
|
||||||
// Parse JSON or simple format
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if strings.HasPrefix(s, "[") {
|
|
||||||
actions, err = parseActionsFromJSON(s)
|
|
||||||
} else {
|
|
||||||
actions, err = parseActionsFromSimple(s)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ID field, ensure correct uppercase/lowercase
|
|
||||||
for i := range actions {
|
|
||||||
actions[i].ID = util.RandomString(actionIDLength)
|
|
||||||
actions[i].Action = strings.ToLower(actions[i].Action)
|
|
||||||
actions[i].Method = strings.ToUpper(actions[i].Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate
|
|
||||||
if len(actions) > actionsMax {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
|
|
||||||
}
|
|
||||||
for _, action := range actions {
|
|
||||||
if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
|
|
||||||
} else if action.Label == "" {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
|
|
||||||
} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
|
|
||||||
} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
|
||||||
actions := make([]*action, 0)
|
|
||||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return actions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
|
||||||
actions := make([]*action, 0)
|
|
||||||
rawActions := util.SplitNoEmpty(s, ";")
|
|
||||||
for _, rawAction := range rawActions {
|
|
||||||
newAction := &action{
|
|
||||||
Headers: make(map[string]string),
|
|
||||||
Extras: make(map[string]string),
|
|
||||||
}
|
|
||||||
parts := util.SplitNoEmpty(rawAction, ",")
|
|
||||||
if len(parts) < 3 {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
|
|
||||||
}
|
|
||||||
for i, part := range parts {
|
|
||||||
key, value := util.SplitKV(part, "=")
|
|
||||||
if key == "" && i == 0 {
|
|
||||||
newAction.Action = value
|
|
||||||
} else if key == "" && i == 1 {
|
|
||||||
newAction.Label = value
|
|
||||||
} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
|
|
||||||
newAction.URL = value
|
|
||||||
} else if strings.HasPrefix(key, "headers.") {
|
|
||||||
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
|
||||||
} else if strings.HasPrefix(key, "extras.") {
|
|
||||||
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
|
||||||
} else if key != "" {
|
|
||||||
switch strings.ToLower(key) {
|
|
||||||
case "action":
|
|
||||||
newAction.Action = value
|
|
||||||
case "label":
|
|
||||||
newAction.Label = value
|
|
||||||
case "clear":
|
|
||||||
lvalue := strings.ToLower(value)
|
|
||||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
|
|
||||||
}
|
|
||||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
|
||||||
case "url":
|
|
||||||
newAction.URL = value
|
|
||||||
case "method":
|
|
||||||
newAction.Method = value
|
|
||||||
case "body":
|
|
||||||
newAction.Body = value
|
|
||||||
default:
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actions = append(actions, newAction)
|
|
||||||
}
|
|
||||||
return actions, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) {
|
||||||
require.Equal(t, false, up)
|
require.Equal(t, false, up)
|
||||||
require.Equal(t, true, firebase)
|
require.Equal(t, true, firebase)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseActions(t *testing.T) {
|
|
||||||
actions, err := parseActions("[]")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Empty(t, actions)
|
|
||||||
|
|
||||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 2, len(actions))
|
|
||||||
require.Equal(t, "http", actions[0].Action)
|
|
||||||
require.Equal(t, "Open door", actions[0].Label)
|
|
||||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
|
||||||
require.Equal(t, "view", actions[1].Action)
|
|
||||||
require.Equal(t, "Show portal", actions[1].Label)
|
|
||||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
|
||||||
|
|
||||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 2, len(actions))
|
|
||||||
require.Equal(t, "http", actions[0].Action)
|
|
||||||
require.Equal(t, "Open door", actions[0].Label)
|
|
||||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
|
||||||
require.Equal(t, "view", actions[1].Action)
|
|
||||||
require.Equal(t, "Show portal", actions[1].Label)
|
|
||||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
|
||||||
|
|
||||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(actions))
|
|
||||||
require.Equal(t, "http", actions[0].Action)
|
|
||||||
require.Equal(t, "Open door", actions[0].Label)
|
|
||||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
|
||||||
require.Equal(t, "PUT", actions[0].Method)
|
|
||||||
require.Equal(t, "this is a body", actions[0].Body)
|
|
||||||
|
|
||||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(actions))
|
|
||||||
require.Equal(t, "broadcast", actions[0].Action)
|
|
||||||
require.Equal(t, "Do a thing", actions[0].Label)
|
|
||||||
require.Equal(t, 2, len(actions[0].Extras))
|
|
||||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
|
||||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
|
||||||
|
|
||||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(actions))
|
|
||||||
require.Equal(t, "http", actions[0].Action)
|
|
||||||
require.Equal(t, "Send request", actions[0].Label)
|
|
||||||
require.Equal(t, 2, len(actions[0].Headers))
|
|
||||||
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
|
||||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue