Browse Source

More docs

Philipp Heckel 2 years ago
parent
commit
a779434bab
7 changed files with 1123 additions and 349 deletions
  1. 739 119
      docs/publish.md
  2. 14 13
      go.mod
  3. 35 0
      go.sum
  4. 19 1
      server/message_cache.go
  5. 10 4
      server/util.go
  6. 18 0
      server/util_test.go
  7. 288 212
      web/package-lock.json

+ 739 - 119
docs/publish.md

@@ -821,11 +821,13 @@ Here's an example of what that a notification with actions can look like:
   <figcaption>XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX</figcaption>
 </figure>
 
-To define the user actions, you can either pass the `actions` field as part of the JSON body (if you're 
-[publishing via JSON](#publish-as-json)), or use the `X-Actions` header (or any of its aliases: `Actions`, `Action`).
+You can set up to three user actions in your notifications, using either of the following methods:
 
-Using the `X-Actions` header and the **simple format** (details see below), you can create the above notification like 
-this. This format is much **easier to write, but less powerful**:
+* In the `X-Actions` header, using the **simple format**
+* As a **JSON array** in the `actions` key, when [publishing as JSON](#publish-as-json) 
+
+Using the `X-Actions` header (or any of its aliases: `Actions`, `Action`) and the **simple format** (details see below), you 
+can create the above notification like this. 
 
 === "Command line (curl)"
     ```
@@ -899,40 +901,29 @@ this. This format is much **easier to write, but less powerful**:
         ]
     ]));
     ```
-
-The `X-Actions` header (including above-mentioned aliases) supports the following formats:
+ 
+Here's the generic definition of the simple format: 
 
 === "Simple format (long)"
     ```
-    X-Actions: action=<action>, label=<label>, param1=..., param2=..., ...
-    ```
-    Simple format examples:
-    ```
-    X-Actions: action=view, label=Play video, url=https://www.youtube.com/watch?v=EmL3lS0-Sr8
-    X-Actions: action=broadcast, label=Turn of flashlight, extras.cmd=flashlight-on
-    X-Actions: action=http, label=Change temperature, url=https://api.nest.com/device/XZ1D2, body=target_temp_f=65
+    action=<action1>, label=<label1>, paramN=...[; action=<action2>, label=<label2>, ...]
     ```
 
 === "Simple format (short)"
     ```
-    Actions: <action>, <label>, param1=..., param2=..., ...
+    <action1>, <label1>, paramN=...[; <action2>, <label2>, ...]
     ```
 
-An `action` is either [`view`](#open-websiteapp), [`broadcast`](#send-android-broadcast), or [`http`](#send-http-request),
-and the `label` defines the button text. The other parameters depend on the action itself. Please refer to this table
-for all available parameters:
+`action=` and `label=` are optional in all actions, and `url=` is optional in the `view` and `http` action.
 
-| Field     | Required | Type                           | Example               | Applies to action | Description                                                  |
-|-----------|----------|--------------------------------|-----------------------|-------------------|--------------------------------------------------------------|
-| `action`  | ✔️       | *view, broadcast, or http*     | `view`                | *all actions*     | Action type                                                  |
-| `label`   | ✔️       | *string*                       | `Turn on light`       | *all actions*     | Label of the action button in the notification               |
-| `url`     | -️       | *URL*                          | `https://example.com` | `view`, `http`    | URL to open or send a HTTP request to                        |
-| `method`  | -️       | *HTTP method (GET, POST, ...)* | `GET`                 | `http`            | HTTP method to use for HTTP request (**default is `POST`**!) |
-| `headers` | -️       | *HTTP method (GET, POST, ...)* | `GET`                 | `http`            | HTTP method to use for HTTP request (**default is `POST`**!) |
-| `method`  | -️       | *HTTP method (GET, POST, ...)* | `GET`                 | `http`            | HTTP method to use for HTTP request (**default is `POST`**!) |
+Simple format examples:
 
+```
+http, Change temp, https://api.nest.com/XZ1D2, body=target_temp=65
+action=view, label=Open site, url=https://ntfy.sh; action=broadcast, label=Turn off, extras.cmd=turn-off
+```
 
-Alternatively, you can define actions as **JSON array** (details see below), and pass them as part of the JSON body 
+Alternatively, the same actions can be defined as **JSON array** (details see below), if the notification is defined as part of the JSON body 
 (see [publish as JSON](#publish-as-json)):
 
 === "Command line (curl)"
@@ -1127,124 +1118,753 @@ Alternatively, you can define actions as **JSON array** (details see below), and
     ]));
     ```
 
-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
-
-
 ### Open website/app
 The `view` action opens a website or app when the action button is tapped, e.g. a browser, a Google Maps location, or
 even a deep link into Twitter or a show ntfy topic.
 
-XXXXXXXXXXXXXXXXXXx
+Examples:
 
+* `http://` or `https://` will open your browser (or an app if it registered for a URL)
+* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`
+* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`
+* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`
+* ...
 
-### Send Android broadcast
-The `broadcast` action sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
-when the action button is tapped. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
-or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
-you can do everything your phone is capable of. Examples include taking pictures, launching/killing apps, change device
-settings, write/read files, etc.
+Here's an example using the simple format:
 
-XXXXXXXXXXXXXXxx
+=== "Command line (curl)"
+    ```
+    curl \
+        -d "You left the house. Turn down the A/C?" \
+        -H "Actions: view, Open portal, https://home.nest.com/" \
+    ntfy.sh/myhome
+    ```
 
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions="view, Open portal, https://home.nest.com/" \
+        myhome \
+        "You left the house. Turn down the A/C?"
+    ```
 
-### Send HTTP request
-The `http` action sends a HTTP POST/GET/PUT request when the action button is tapped. You can use this to trigger REST APIs
-for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
+=== "HTTP"
+    ``` http
+    POST /myhome HTTP/1.1
+    Host: ntfy.sh
+    Actions: view, Open portal, https://home.nest.com/
 
-XXXXXXXXXXXXXXXXXXXXx
+    You left the house. Turn down the A/C?
+    ```
 
-=== "`view` action"
-    ``` json
-    { 
-      "action": "view", 
-      "label": "Open bing.com", 
-      "url": "https://bing.com"
-    }
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/myhome', {
+        method: 'POST',
+        body: 'You left the house. Turn down the A/C?',
+        headers: { 
+            'Actions': 'view, Open portal, https://home.nest.com/' 
+        }
+    })
     ```
 
-=== "`broadcast` action"
-    ``` json
-    { 
-      "action": "broadcast", 
-      "label": "Send broadcast", 
-      "intent": "io.heckel.ntfy.USER_ACTION",
-      "extras": {
-        "param": "this is a param",
-        "anotherparam": "this is another one"
-      }
-    }
+=== "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/")
+    http.DefaultClient.Do(req)
     ```
 
-=== "`http` action"
-    ``` json
-    { 
-      "action": "http", 
-      "label": "Take picture", 
-      "method": "POST",
-      "url": "https://homecam.lan/capture",
-      "headers": {
-        "Authorization": "..."
-      },
-      "body": "this is a message"
-    }
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh/myhome"
+    $headers = @{ Actions="view, Open portal, https://home.nest.com/" }
+    $body = "You left the house. Turn down the A/C?"
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
     ```
 
-Examples:
+=== "Python"
+    ``` 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/" })
+    ```
 
-=== "Open a website"
-    ``` json
-    { 
-      "action": "view", 
-      "label": "Open bing.com", 
-      "url": "https://bing.com"
-    }
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' =>
+                "Content-Type: text/plain\r\n" .
+                "Actions: view, Open portal, https://home.nest.com/",
+            'content' => 'You left the house. Turn down the A/C?'
+        ]
+    ]));
     ```
 
-=== "Open location in Google Maps"
-    ``` json
-    { 
-      "action": "view", 
-      "label": "Show map", 
-      "url": "geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+California"
-    }
+And the same example using [JSON publishing](#publish-as-json):
+
+=== "Command line (curl)"
+    ```
+    curl ntfy.sh \
+      -d '{
+        "topic": "myhome",
+        "message": "You left the house. Turn down the A/C?",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open portal",
+            "url": "https://home.nest.com/"
+          }
+        ]
+      }'
     ```
 
-=== "Open a ntfy topic (deep link)"
-    ``` json
-    {
-      "action": "view",
-      "label": "Show stats",
-      "url": "ntfy://ntfy.sh/stats"
-    }
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions '[
+            {
+                "action": "view",
+                "label": "Open portal",
+                "url": "https://home.nest.com/"
+            }
+        ]' \
+        myhome \
+        "You left the house. Turn down the A/C?"
     ```
 
-=== "Send broadcast"
-    ``` json
+=== "HTTP"
+    ``` http
+    POST / HTTP/1.1
+    Host: ntfy.sh
+
     {
-      "action": "broadcast",
-      "label": "Send broadcast",
-      "intent": "my.custom.intent",
-      "extras": {
-        "message": "whats up, hello"
-      }
-    }  
-    ```
-
-=== "Send a ntfy message"
-    ``` json
-    { 
-      "action": "http", 
-      "label": "Send message", 
-      "method": "POST",
-      "url": "http://ntfy.example.com/mytopic",
-      "headers": {
-        "Title": "another message",
-        "Tags": "tag1, tag2"
-      },
-      "body": "this is a message"
+        "topic": "myhome",
+        "message": "You left the house. Turn down the A/C?",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open portal",
+            "url": "https://home.nest.com/"
+          }
+        ]
     }
     ```
 
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh', {
+        method: 'POST',
+        body: JSON.stringify({
+            topic: "myhome",
+            message": "You left the house. Turn down the A/C?",
+            actions: [
+                {
+                    action: "view",
+                    label: "Open portal",
+                    url: "https://home.nest.com/"
+                }
+            ]
+        })
+    })
+    ```
+
+=== "Go"
+    ``` go
+    // You should probably use json.Marshal() instead and make a proper struct,
+    // but for the sake of the example, this is easier.
+    
+    body := `{
+        "topic": "myhome",
+        "message": "You left the house. Turn down the A/C?",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open portal",
+            "url": "https://home.nest.com/"
+          }
+        ]
+    }`
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh"
+    $body = @{
+        "topic"="myhome"
+        "message"="You left the house. Turn down the A/C?"
+        "actions"=@(
+            @{
+                "action"="view"
+                "label"="Open portal"
+                "url"="https://home.nest.com/"
+            }
+        )
+    } | ConvertTo-Json
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/",
+        data=json.dumps({
+            "topic": "myhome",
+            "message": "You left the house. Turn down the A/C?",
+            "actions": [
+                {
+                    "action": "view",
+                    "label": "Open portal",
+                    "url": "https://home.nest.com/"
+                }
+            ]
+        })
+    )
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' => "Content-Type: application/json",
+            'content' => json_encode([
+                "topic": "myhome",
+                "message": "You left the house. Turn down the A/C?",
+                "actions": [
+                    [
+                        "action": "view",
+                        "label": "Open portal",
+                        "url": "https://home.nest.com/"
+                    ]
+                ]
+            ])
+        ]
+    ]));
+    ```
+
+The `view` action supports the following fields:
+
+| Field    | Required | Type     | Example               | Description                                    |
+|----------|----------|----------|-----------------------|------------------------------------------------|
+| `action` | ✔️       | *string* | `view`                | Action type (**must be `view`**)               |
+| `label`  | ✔️       | *string* | `Turn on light`       | Label of the action button in the notification |
+| `url`    | ✔️       | *URL*    | `https://example.com` | URL to open when action is tapped              |
+
+### Send Android broadcast
+The `broadcast` action sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
+when the action button is tapped. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
+or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
+you can do everything your phone is capable of. Examples include taking pictures, launching/killing apps, change device
+settings, write/read files, etc.
+
+Here's an example using the simple format:
+
+=== "Command line (curl)"
+    ```
+    curl \
+        -d "Your wife requested you send a picture of yourself." \
+        -H "Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front" \
+    ntfy.sh/wifey
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" \
+        wifey \
+        "Your wife requested you send a picture of yourself."
+    ```
+
+=== "HTTP"
+    ``` http
+    POST /wifey HTTP/1.1
+    Host: ntfy.sh
+    Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front
+
+    Your wife requested you send a picture of yourself.
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/wifey', {
+        method: 'POST',
+        body: 'Your wife requested you send a picture of yourself.',
+        headers: { 
+            'Actions': 'broadcast, Take picture, extras.cmd=pic, extras.camera=front' 
+        }
+    })
+    ```
+
+=== "Go"
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/wifey", strings.NewReader("Your wife requested you send a picture of yourself."))
+    req.Header.Set("Actions", "broadcast, Take picture, extras.cmd=pic, extras.camera=front")
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh/wifey"
+    $headers = @{ Actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" }
+    $body = "Your wife requested you send a picture of yourself."
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/wifey",
+        data="Your wife requested you send a picture of yourself.",
+        headers={ "Actions": "broadcast, Take picture, extras.cmd=pic, extras.camera=front" })
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/wifey', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' =>
+                "Content-Type: text/plain\r\n" .
+                "Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front",
+            'content' => 'Your wife requested you send a picture of yourself.'
+        ]
+    ]));
+    ```
+
+And the same example using [JSON publishing](#publish-as-json):
+
+=== "Command line (curl)"
+    ```
+    curl ntfy.sh \
+      -d '{
+        "topic": "wifey",
+        "message": "Your wife requested you send a picture of yourself.",
+        "actions": [
+          {
+            "action": "broadcast",
+            "label": "Take picture",
+            "extras": {
+                "cmd": "pic",
+                "camera": "front"
+            }
+          }
+        ]
+      }'
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions '[
+            {
+                "action": "broadcast",
+                "label": "Take picture",
+                "extras": {
+                    "cmd": "pic",
+                    "camera": "front"
+                }
+            }
+        ]' \
+        wifey \
+        "Your wife requested you send a picture of yourself."
+    ```
+
+=== "HTTP"
+    ``` http
+    POST / HTTP/1.1
+    Host: ntfy.sh
+
+    {
+        "topic": "wifey",
+        "message": "Your wife requested you send a picture of yourself.",
+        "actions": [
+          {
+            "action": "broadcast",
+            "label": "Take picture",
+            "extras": {
+                "cmd": "pic",
+                "camera": "front"
+            }
+          }
+        ]
+    }
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh', {
+        method: 'POST',
+        body: JSON.stringify({
+            topic: "wifey",
+            message": "Your wife requested you send a picture of yourself.",
+            actions: [
+                {
+                    "action": "broadcast",
+                    "label": "Take picture",
+                    "extras": {
+                        "cmd": "pic",
+                        "camera": "front"
+                    }
+                }
+            ]
+        })
+    })
+    ```
+
+=== "Go"
+    ``` go
+    // You should probably use json.Marshal() instead and make a proper struct,
+    // but for the sake of the example, this is easier.
+    
+    body := `{
+        "topic": "wifey",
+        "message": "Your wife requested you send a picture of yourself.",
+        "actions": [
+          {
+            "action": "broadcast",
+            "label": "Take picture",
+            "extras": {
+                "cmd": "pic",
+                "camera": "front"
+            }
+          }
+        ]
+    }`
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh"
+    $body = @{
+        "topic"="wifey"
+        "message"="Your wife requested you send a picture of yourself."
+        "actions"=@(
+            @{
+                "action"="broadcast"
+                "label"="Take picture"
+                "extras"=@{
+                    "cmd"="pic"
+                    "camera"="front"
+                }
+            }
+        )
+    } | ConvertTo-Json
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/",
+        data=json.dumps({
+            "topic": "wifey",
+            "message": "Your wife requested you send a picture of yourself.",
+            "actions": [
+                {
+                    "action": "broadcast",
+                    "label": "Take picture",
+                    "extras": {
+                        "cmd": "pic",
+                        "camera": "front"
+                    }
+                }
+            ]
+        })
+    )
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' => "Content-Type: application/json",
+            'content' => json_encode([
+                "topic": "wifey",
+                "message": "Your wife requested you send a picture of yourself.",
+                "actions": [
+                    [
+                    "action": "broadcast",
+                    "label": "Take picture",
+                    "extras": [
+                        "cmd": "pic",
+                        "camera": "front"
+                    ]
+                ]
+            ])
+        ]
+    ]));
+    ```
+
+The `broadcast` action supports the following fields:
+
+| Field    | Required | Type             | Example                 | Description                                                                                                                                                                            |
+|----------|----------|------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `action` | ✔️       | *string*         | `broadcast`             | Action type (**must be `broadcast`**)                                                                                                                                                  |
+| `label`  | ✔️       | *string*         | `Turn on light`         | Label of the action button in the notification                                                                                                                                         |
+| `intent` | -️       | *string*         | `com.example.AN_INTENT` | Android intent name, **default is `io.heckel.ntfy.USER_ACTION`**                                                                                                                       |
+| `extras` | -️       | *map of strings* | *see above*             | Android intent extras. Currently, only string extras are supported. When publishing as JSON, extras are passed as a map. When the simple format is used, use `extras.<param>=<value>`. |
+
+### Send HTTP request
+The `http` action sends a HTTP POST/GET/PUT request when the action button is tapped. You can use this to trigger REST APIs
+for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
+
+Here's an example using the simple format:
+
+=== "Command line (curl)"
+    ```
+    curl \
+        -d "Garage door has been open for 15 minutes. Close it?" \
+        -H "Actions: http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk.." \
+        ntfy.sh/myhome
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions="http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk.." \
+        myhome \
+        "Garage door has been open for 15 minutes. Close it?"
+    ```
+
+=== "HTTP"
+    ``` http
+    POST /myhome HTTP/1.1
+    Host: ntfy.sh
+    Actions: http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk..
+
+    Garage door has been open for 15 minutes. Close it?
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/myhome', {
+        method: 'POST',
+        body: 'Garage door has been open for 15 minutes. Close it?',
+        headers: { 
+            'Actions': 'http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk..' 
+        }
+    })
+    ```
+
+=== "Go"
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
+    req.Header.Set("Actions", "http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk..")
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh/myhome"
+    $headers = @{ Actions="http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk.." }
+    $body = "Garage door has been open for 15 minutes. Close it?"
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/myhome",
+        data="Garage door has been open for 15 minutes. Close it?",
+        headers={ "Actions": "http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk.." })
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' =>
+                "Content-Type: text/plain\r\n" .
+                "Actions: http, Cloor door, https://mygarage.lan/close, headers.Authorization=Bearer zAzsx1sk..",
+            'content' => 'Garage door has been open for 15 minutes. Close it?'
+        ]
+    ]));
+    ```
+
+And the same example using [JSON publishing](#publish-as-json):
+
+=== "Command line (curl)"
+    ```
+    curl ntfy.sh \
+      -d '{
+        "topic": "myhome",
+        "message": "Garage door has been open for 15 minutes. Close it?",
+        "actions": [
+          {
+            "action": "http",
+            "label": "Close door",
+            "url": "https://mygarage.lan/close",
+            "headers": {
+                "Authorization": "Bearer zAzsx1sk.."
+            }
+          }
+        ]
+      }'
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --actions '[
+            {
+              "action": "http",
+              "label": "Close door",
+              "url": "https://mygarage.lan/close",
+              "headers": {
+                "Authorization": "Bearer zAzsx1sk.."
+              }
+            }
+        ]' \
+        myhome \
+        "Garage door has been open for 15 minutes. Close it?"
+    ```
+
+=== "HTTP"
+    ``` http
+    POST / HTTP/1.1
+    Host: ntfy.sh
+
+    {
+        "topic": "myhome",
+        "message": "Garage door has been open for 15 minutes. Close it?",
+        "actions": [
+          {
+            "action": "http",
+            "label": "Close door",
+            "url": "https://mygarage.lan/close",
+            "headers": {
+              "Authorization": "Bearer zAzsx1sk.."
+            }
+          }
+        ]
+    }
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh', {
+        method: 'POST',
+        body: JSON.stringify({
+            topic: "myhome",
+            message": "Garage door has been open for 15 minutes. Close it?",
+            actions: [
+              {
+                "action": "http",
+                "label": "Close door",
+                "url": "https://mygarage.lan/close",
+                "headers": {
+                  "Authorization": "Bearer zAzsx1sk.."
+                }
+              }
+            ]
+        })
+    })
+    ```
+
+=== "Go"
+    ``` go
+    // You should probably use json.Marshal() instead and make a proper struct,
+    // but for the sake of the example, this is easier.
+    
+    body := `{
+        "topic": "myhome",
+        "message": "Garage door has been open for 15 minutes. Close it?",
+        "actions": [
+          {
+            "action": "http",
+            "label": "Close door",
+            "url": "https://mygarage.lan/close",
+            "headers": {
+              "Authorization": "Bearer zAzsx1sk.."
+            }
+          }
+        ]
+    }`
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $uri = "https://ntfy.sh"
+    $body = @{
+        "topic"="myhome"
+        "message"="Garage door has been open for 15 minutes. Close it?"
+        "actions"=@(
+            @{
+                "action"="http",
+                "label"="Close door"
+                "url"="https://mygarage.lan/close"
+                "headers"=@{
+                  "Authorization"="Bearer zAzsx1sk.."
+                }
+            }
+          }
+        )
+    } | ConvertTo-Json
+    Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/",
+        data=json.dumps({
+            "topic": "myhome",
+            "message": "Garage door has been open for 15 minutes. Close it?",
+            "actions": [
+                {
+                  "action": "http",
+                  "label": "Close door",
+                  "url": "https://mygarage.lan/close",
+                  "headers": {
+                    "Authorization": "Bearer zAzsx1sk.."
+                  }
+                }
+            ]
+        })
+    )
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/', false, stream_context_create([
+        'http' => [
+            'method' => 'POST',
+            'header' => "Content-Type: application/json",
+            'content' => json_encode([
+                "topic": "myhome",
+                "message": "Garage door has been open for 15 minutes. Close it?",
+                "actions": [
+                    [
+                        "action": "http",
+                        "label": "Close door",
+                        "url": "https://mygarage.lan/close",
+                        "headers": [
+                            "Authorization": "Bearer zAzsx1sk.."
+                         ]
+                    ]
+                ]
+            ])
+        ]
+    ]));
+    ```
+
+The `http` action supports the following fields:
+
+| Field     | Required | Type               | Example                   | Description                                                                                                                                             |
+|-----------|----------|--------------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `action`  | ✔️       | *string*           | `http`                    | Action type (**must be `http`**)                                                                                                                        |
+| `label`   | ✔️       | *string*           | `Open garage door`        | Label of the action button in the notification                                                                                                          |
+| `url`     | ✔️       | *string*           | `https://ntfy.sh/mytopic` | URL to which the HTTP request will be sent                                                                                                              |
+| `method`  | -️       | *GET/POST/PUT/...* | `GET`                     | HTTP method to use for request, **default is POST (!)**                                                                                                 |
+| `headers` | -️       | *map of strings*   | *see above*               | HTTP headers to pass in request. When publishing as JSON, headers are passed as a map. When the simple format is used, use `headers.<header1>=<value>`. |
+| `method`  | -️       | *string*           | `some body, somebody?`    | HTTP body                                                                                                                                               |
+
 ## Click action
 You can define which URL to open when a notification is clicked. This may be useful if your notification is related 
 to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
@@ -1257,9 +1877,9 @@ by another app, the responsible app may open.
 Examples:
 
 * `http://` or `https://` will open your browser (or an app if it registered for a URL)
-* `mailto:` links will open your mail app
-* `geo:` links will open Google Maps (or your maps application)
-* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links))
+* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`
+* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`
+* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`
 * ...
 
 Here's an example that will open Reddit when the notification is clicked:

+ 14 - 13
go.mod

@@ -4,7 +4,7 @@ go 1.17
 
 require (
 	cloud.google.com/go/firestore v1.6.1 // indirect
-	cloud.google.com/go/storage v1.21.0 // indirect
+	cloud.google.com/go/storage v1.22.0 // indirect
 	firebase.google.com/go v3.13.0+incompatible
 	github.com/BurntSushi/toml v1.1.0 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
@@ -15,20 +15,20 @@ require (
 	github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
 	github.com/stretchr/testify v1.7.0
 	github.com/urfave/cli/v2 v2.4.0
-	golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
-	golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
+	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
+	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
-	golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
-	google.golang.org/api v0.74.0
+	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
+	golang.org/x/time v0.0.0-20220411224347-583f2d630306
+	google.golang.org/api v0.75.0
 	gopkg.in/yaml.v2 v2.4.0
 )
 
 require github.com/pkg/errors v0.9.1
 
 require (
-	cloud.google.com/go v0.100.2 // indirect
-	cloud.google.com/go/compute v1.5.0 // indirect
+	cloud.google.com/go v0.101.0 // indirect
+	cloud.google.com/go/compute v1.6.0 // indirect
 	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/AlekSi/pointer v1.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -36,16 +36,17 @@ require (
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.7 // indirect
-	github.com/googleapis/gax-go/v2 v2.2.0 // indirect
+	github.com/googleapis/gax-go/v2 v2.3.0 // indirect
+	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
-	golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 // indirect
+	golang.org/x/net v0.0.0-20220420153159-1850ba15e1be // indirect
+	golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
 	golang.org/x/text v0.3.7 // indirect
-	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+	golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
+	google.golang.org/genproto v0.0.0-20220420195807-44278fea765b // indirect
 	google.golang.org/grpc v1.45.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect

+ 35 - 0
go.sum

@@ -29,6 +29,8 @@ cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2Z
 cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
 cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
 cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
+cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
+cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -40,6 +42,8 @@ cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC
 cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
 cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
 cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
+cloud.google.com/go/compute v1.6.0 h1:XdQIN5mdPTSBVwSIVDuY5e8ZzVAccsHvD3qTEz4zIps=
+cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
@@ -58,6 +62,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
 cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
+cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
+cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
 firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
@@ -166,6 +172,8 @@ github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIG
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
 github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
+github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
+github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -188,6 +196,10 @@ github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pf
 github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
 github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
 github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
+github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
+github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
+github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
+github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
@@ -248,6 +260,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
 golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -325,6 +339,9 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
 golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
 golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220420153159-1850ba15e1be h1:yx80W7nvY5ySWpaU8UWaj5o9e23YgO9BRhQol7Lc+JI=
+golang.org/x/net v0.0.0-20220420153159-1850ba15e1be/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -345,6 +362,8 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
 golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -414,9 +433,13 @@ golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
 golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -432,6 +455,8 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
 golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -488,6 +513,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -528,6 +555,8 @@ google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/S
 google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
 google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE=
 google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
+google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
+google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -575,6 +604,7 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
@@ -613,6 +643,11 @@ google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2
 google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
 google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf h1:JTjwKJX9erVpsw17w+OIPP7iAgEkN/r8urhWSunEDTs=
 google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220420195807-44278fea765b h1:5zvsLqz9A1TKTeI6AhjJH/Vkaw0GGBs+D3GkvUUqNO0=
+google.golang.org/genproto v0.0.0-20220420195807-44278fea765b/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

+ 19 - 1
server/message_cache.go

@@ -90,7 +90,7 @@ const (
 
 // Schema management queries
 const (
-	currentSchemaVersion          = 5
+	currentSchemaVersion          = 6
 	createSchemaVersionTableQuery = `
 		CREATE TABLE IF NOT EXISTS schemaVersion (
 			id INT PRIMARY KEY,
@@ -168,6 +168,11 @@ const (
 		ALTER TABLE messages_new RENAME TO messages;
 		COMMIT;
 	`
+
+	// 5 -> 6
+	migrate5To6AlterMessagesTableQuery = `
+		ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
+	`
 )
 
 type messageCache struct {
@@ -509,6 +514,8 @@ func setupCacheDB(db *sql.DB) error {
 		return migrateFrom3(db)
 	} else if schemaVersion == 4 {
 		return migrateFrom4(db)
+	} else if schemaVersion == 5 {
+		return migrateFrom5(db)
 	}
 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
 }
@@ -581,5 +588,16 @@ func migrateFrom4(db *sql.DB) error {
 	if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
 		return err
 	}
+	return migrateFrom5(db)
+}
+
+func migrateFrom5(db *sql.DB) error {
+	log.Print("Migrating cache database schema: from 5 to 6")
+	if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
+		return err
+	}
+	if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
+		return err
+	}
 	return nil // Update this when a new version is added
 }

+ 10 - 4
server/util.go

@@ -3,7 +3,6 @@ package server
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/pkg/errors"
 	"heckel.io/ntfy/util"
 	"net/http"
 	"strings"
@@ -96,7 +95,10 @@ func parseActionsFromSimple(s string) ([]*action, error) {
 	actions := make([]*action, 0)
 	rawActions := util.SplitNoEmpty(s, ";")
 	for _, rawAction := range rawActions {
-		newAction := &action{}
+		newAction := &action{
+			Headers: make(map[string]string),
+			Extras:  make(map[string]string),
+		}
 		parts := util.SplitNoEmpty(rawAction, ",")
 		if len(parts) < 3 {
 			return nil, fmt.Errorf("cannot parse action: action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
@@ -109,6 +111,10 @@ func parseActionsFromSimple(s string) ([]*action, error) {
 				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":
@@ -122,10 +128,10 @@ func parseActionsFromSimple(s string) ([]*action, error) {
 				case "body":
 					newAction.Body = value
 				default:
-					return nil, errors.Errorf("cannot parse action: key '%s' not supported, please use JSON format instead", part)
+					return nil, fmt.Errorf("cannot parse action: key '%s' not supported, please use JSON format instead", part)
 				}
 			} else {
-				return nil, errors.Errorf("cannot parse action: unknown phrase '%s'", part)
+				return nil, fmt.Errorf("cannot parse action: unknown phrase '%s'", part)
 			}
 		}
 		actions = append(actions, newAction)

+ 18 - 0
server/util_test.go

@@ -61,4 +61,22 @@ func TestParseActions(t *testing.T) {
 	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"])
 }

File diff suppressed because it is too large
+ 288 - 212
web/package-lock.json


Some files were not shown because too many files changed in this diff