From 6bd4e4bd7cdd5d3fd39b75d25fb0c75145335c66 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Wed, 27 Apr 2022 10:25:01 -0400
Subject: [PATCH] User actions docs, tests and release notes

---
 docs/publish.md        | 61 +++++++++++++++++++++++-------------------
 docs/releases.md       | 16 +++++++++++
 server/actions.go      |  6 ++---
 server/actions_test.go | 43 ++++++++++++++++++++++++-----
 4 files changed, 88 insertions(+), 38 deletions(-)

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`
     <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.
-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.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
 Released Apr 25, 2022
 
diff --git a/server/actions.go b/server/actions.go
index 296a649a..59852838 100644
--- a/server/actions.go
+++ b/server/actions.go
@@ -134,7 +134,6 @@ func (p *actionParser) parseAction() (*action, error) {
 	section := 0
 	for {
 		key, value, last, err := p.parseSection()
-		fmt.Printf("--> key=%s, value=%s, last=%t, err=%#v\n", key, value, last, err)
 		if err != nil {
 			return nil, err
 		}
@@ -226,14 +225,15 @@ func (p *actionParser) parseKey() string {
 }
 
 // parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
-// this function does not support "," or ";" in the value itself.
+// 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 = p.input[start:p.pos]
+			value = strings.TrimSpace(p.input[start:p.pos])
 			p.pos += w
 			return
 		}
diff --git a/server/actions_test.go b/server/actions_test.go
index da15c5f4..4f16bea0 100644
--- a/server/actions_test.go
+++ b/server/actions_test.go
@@ -78,6 +78,15 @@ func TestParseActions(t *testing.T) {
 	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)
@@ -102,25 +111,45 @@ func TestParseActions(t *testing.T) {
 	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
-	actions, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
+	_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
 	require.EqualError(t, err, "unexpected character 'x' at position 22")
 
-	actions, err = parseActions(`label="", action="http", url=http://example.com`)
+	_, err = parseActions(`label="", action="http", url=http://example.com`)
 	require.EqualError(t, err, "parameter 'label' is required")
 
-	actions, err = parseActions(`label=, action="http", url=http://example.com`)
+	_, err = parseActions(`label=, action="http", url=http://example.com`)
 	require.EqualError(t, err, "parameter 'label' is required")
 
-	actions, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
+	_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
 	require.EqualError(t, err, "term 'what is this anyway' unknown")
 
-	actions, err = parseActions(`fdsfdsf`)
+	_, err = parseActions(`fdsfdsf`)
 	require.EqualError(t, err, "action 'fdsfdsf' unknown")
 
-	actions, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
+	_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
 	require.EqualError(t, err, "key 'aaa' unknown")
 
-	actions, err = parseActions(`action=http, label="omg the end quote is missing`)
+	_, 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")
 }