diff --git a/docs/publish.md b/docs/publish.md index 5229a537..48d8ca56 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -850,27 +850,32 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions` , , paramN=... [; , , ...] ``` -The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and `http` action. -The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8 -characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations. +Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be +quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons. + +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 [`http` action](#send-http-request) section for details on the specific actions: === "Command line (curl)" ``` + body='{"temperature": 65}' curl \ -d "You left the house. Turn down the A/C?" \ -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" \ - ntfy.sh/myhome + http, Turn down, https://api.nest.com/, body='$body'" \ + ntfy.sh/myhome ``` === "ntfy CLI" ``` + body='{"temperature": 65}' ntfy publish \ --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 \ "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 POST /myhome HTTP/1.1 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? ``` @@ -890,7 +895,7 @@ As an example, here's how you can create the above notification using this forma method: 'POST', body: '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' + '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 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) ``` === "PowerShell" ``` powershell $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?" 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 requests.post("https://ntfy.sh/myhome", 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" @@ -924,7 +929,7 @@ As an example, here's how you can create the above notification using this forma 'method' => 'POST', 'header' => "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?' ] ])); @@ -950,8 +955,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", - "body": "target_temp_f=65" + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" } ] }' @@ -970,8 +975,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", - "body": "target_temp_f=65" + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" } ]' \ myhome \ @@ -996,8 +1001,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", - "body": "target_temp_f=65" + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" } ] } @@ -1020,8 +1025,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { action: "http", label: "Turn down", - url: "https://api.nest.com/device/XZ1D2", - body: "target_temp_f=65" + url: "https://api.nest.com/", + body: "{\"temperature\": 65}" } ] }) @@ -1046,8 +1051,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", - "body": "target_temp_f=65" + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" } ] }` @@ -1071,8 +1076,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific @{ "action"="http", "label"="Turn down" - "url"="https://api.nest.com/device/XZ1D2" - "body"="target_temp_f=65" + "url"="https://api.nest.com/" + "body"="{\"temperature\": 65}" } ) } | ConvertTo-Json @@ -1095,8 +1100,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific { "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", - "body": "target_temp_f=65" + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" } ] }) @@ -1122,11 +1127,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific [ "action": "http", "label": "Turn down", - "url": "https://api.nest.com/device/XZ1D2", + "url": "https://api.nest.com/", "headers": [ "Authorization": "Bearer ..." ], - "body": "target_temp_f=65" + "body": "{\"temperature\": 65}" ] ] ]) diff --git a/docs/releases.md b/docs/releases.md index 6888e625..c44577bd 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). + + ## ntfy Android app v1.12.0 Released Apr 25, 2022 diff --git a/server/actions.go b/server/actions.go new file mode 100644 index 00000000..320c79cd --- /dev/null +++ b/server/actions.go @@ -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 == ';' +} diff --git a/server/actions_test.go b/server/actions_test.go new file mode 100644 index 00000000..4f16bea0 --- /dev/null +++ b/server/actions_test.go @@ -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") +} diff --git a/server/server.go b/server/server.go index b3336da3..f4369f89 100644 --- a/server/server.go +++ b/server/server.go @@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca if actionsStr != "" { m.Actions, err = parseActions(actionsStr) 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! diff --git a/server/types.go b/server/types.go index 6e40c345..8c4f125b 100644 --- a/server/types.go +++ b/server/types.go @@ -56,6 +56,13 @@ type action struct { 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 type publishMessage struct { Topic string `json:"topic"` diff --git a/server/util.go b/server/util.go index 34d706f8..7c596344 100644 --- a/server/util.go +++ b/server/util.go @@ -1,17 +1,10 @@ package server import ( - "encoding/json" - "heckel.io/ntfy/util" "net/http" "strings" ) -const ( - actionIDLength = 10 - actionsMax = 3 -) - func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { value := strings.ToLower(readParam(r, names...)) if value == "" { @@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string { } 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 -} diff --git a/server/util_test.go b/server/util_test.go index 9386cd84..63bc6b40 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) { require.Equal(t, false, up) 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"]) -}