Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03a37feb1 | ||
|
|
4cd556f5aa | ||
|
|
90aeb811ff | ||
|
|
c6ab380ea4 | ||
|
|
7860f2142c | ||
|
|
18d5d31bd2 | ||
|
|
cfdc364e3f | ||
|
|
763215ecfa | ||
|
|
3f0a7b65ee | ||
|
|
65050ef4dc | ||
|
|
49991d5aa7 |
@@ -40,6 +40,7 @@ ADD ./log ./log
|
|||||||
ADD ./server ./server
|
ADD ./server ./server
|
||||||
ADD ./user ./user
|
ADD ./user ./user
|
||||||
ADD ./util ./util
|
ADD ./util ./util
|
||||||
|
ADD ./payments ./payments
|
||||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|||||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.17.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.17.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.17.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.17.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -124,7 +124,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -132,7 +132,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -140,7 +140,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -150,28 +150,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -213,18 +213,18 @@ pkg install go-ntfy
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.16.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.17.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ brew install ntfy
|
|||||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||||
To install, you can either
|
To install, you can either
|
||||||
|
|
||||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_windows_amd64.zip),
|
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_windows_amd64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||||
|
|
||||||
|
|||||||
271
docs/publish.md
271
docs/publish.md
@@ -1134,6 +1134,7 @@ As of today, the following actions are supported:
|
|||||||
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||||
when the action button is tapped (only supported on Android)
|
when the action button is tapped (only supported on Android)
|
||||||
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
||||||
|
* [`copy`](#copy-to-clipboard): Copies a given value to the clipboard when the action button is tapped
|
||||||
|
|
||||||
Here's an example of what a notification with actions can look like:
|
Here's an example of what a notification with actions can look like:
|
||||||
|
|
||||||
@@ -1164,9 +1165,12 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
|
|||||||
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
|
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.
|
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
|
Each action type has a short format where some key prefixes can be omitted:
|
||||||
`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.
|
* [`view`](#open-websiteapp): `view, <label>, <url>[, clear=true]`
|
||||||
|
* [`broadcast`](#send-android-broadcast):`broadcast, <label>[, extras.<param>=<value>][, intent=<intent>][, clear=true]`
|
||||||
|
* [`http`](#send-http-request): `http, <label>, <url>[, method=<method>][, headers.<header>=<value>][, body=<body>][, clear=true]`
|
||||||
|
* [`copy`](#copy-to-clipboard): `copy, <label>, <value>[, clear=true]`
|
||||||
|
|
||||||
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
||||||
[`http` action](#send-http-request) section for details on the specific actions:
|
[`http` action](#send-http-request) section for details on the specific actions:
|
||||||
@@ -1466,8 +1470,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
|||||||
```
|
```
|
||||||
|
|
||||||
The required/optional fields for each action depend on the type of the action itself. Please refer to
|
The required/optional fields for each action depend on the type of the action itself. Please refer to
|
||||||
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), [`http` action](#send-http-request),
|
||||||
for details.
|
and [`copy` action](#copy-to-clipboard) for details.
|
||||||
|
|
||||||
### Open website/app
|
### Open website/app
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
@@ -1710,6 +1714,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The short format for the `view` action is `view, <label>, <url>` (e.g. `view, Open Google, https://google.com`),
|
||||||
|
but you can always just use the `<key>=<value>` notation as well (e.g. `action=view, url=https://google.com, label=Open Google`).
|
||||||
|
|
||||||
The `view` action supports the following fields:
|
The `view` action supports the following fields:
|
||||||
|
|
||||||
| Field | Required | Type | Default | Example | Description |
|
| Field | Required | Type | Default | Example | Description |
|
||||||
@@ -1986,6 +1993,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The short format for the `broadcast` action is `broadcast, <label>, <url>` (e.g. `broadcast, Take picture, extras.cmd=pic`),
|
||||||
|
but you can always just use the `<key>=<value>` notation as well (e.g. `action=broadcast, label=Take picture, extras.cmd=pic`).
|
||||||
|
|
||||||
The `broadcast` action supports the following fields:
|
The `broadcast` action supports the following fields:
|
||||||
|
|
||||||
| Field | Required | Type | Default | Example | Description |
|
| Field | Required | Type | Default | Example | Description |
|
||||||
@@ -2273,6 +2283,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The short format for the `http` action is `http, <label>, <url>` (e.g. `http, Close door, https://api.mygarage.lan/close`),
|
||||||
|
but you can always just use the `<key>=<value>` notation as well (e.g. `action=http, label=Close door, url=https://api.mygarage.lan/close`).
|
||||||
|
|
||||||
The `http` action supports the following fields:
|
The `http` action supports the following fields:
|
||||||
|
|
||||||
| Field | Required | Type | Default | Example | Description |
|
| Field | Required | Type | Default | Example | Description |
|
||||||
@@ -2285,6 +2298,254 @@ The `http` action supports the following fields:
|
|||||||
| `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body |
|
| `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body |
|
||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
||||||
|
|
||||||
|
### Copy to clipboard
|
||||||
|
_Supported on:_ :material-android: :material-firefox:
|
||||||
|
|
||||||
|
The `copy` action **copies a given value to the clipboard when the action button is tapped**. This is useful for
|
||||||
|
one-time passcodes, tokens, or any other value you want to quickly copy without opening the full notification.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
The copy action button is only shown in the web app and Android app notification list, **not** in browser desktop
|
||||||
|
notifications. This is because browsers do not allow clipboard access from notification actions without direct
|
||||||
|
user interaction with the page.
|
||||||
|
|
||||||
|
Here's an example using the [`X-Actions` header](#using-a-header):
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-d "Your one-time passcode is 123456" \
|
||||||
|
-H "Actions: copy, Copy code, 123456" \
|
||||||
|
ntfy.sh/myhome
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--actions="copy, Copy code, 123456" \
|
||||||
|
myhome \
|
||||||
|
"Your one-time passcode is 123456"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /myhome HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Actions: copy, Copy code, 123456
|
||||||
|
|
||||||
|
Your one-time passcode is 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/myhome', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'Your one-time passcode is 123456',
|
||||||
|
headers: {
|
||||||
|
'Actions': 'copy, Copy code, 123456'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Your one-time passcode is 123456"))
|
||||||
|
req.Header.Set("Actions", "copy, Copy code, 123456")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$Request = @{
|
||||||
|
Method = "POST"
|
||||||
|
URI = "https://ntfy.sh/myhome"
|
||||||
|
Headers = @{
|
||||||
|
Actions = "copy, Copy code, 123456"
|
||||||
|
}
|
||||||
|
Body = "Your one-time passcode is 123456"
|
||||||
|
}
|
||||||
|
Invoke-RestMethod @Request
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/myhome",
|
||||||
|
data="Your one-time passcode is 123456",
|
||||||
|
headers={ "Actions": "copy, Copy code, 123456" })
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/myhome', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" .
|
||||||
|
"Actions: copy, Copy code, 123456",
|
||||||
|
'content' => 'Your one-time passcode is 123456'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
And the same example using [JSON publishing](#publish-as-json):
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl ntfy.sh \
|
||||||
|
-d '{
|
||||||
|
"topic": "myhome",
|
||||||
|
"message": "Your one-time passcode is 123456",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--actions '[
|
||||||
|
{
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
}
|
||||||
|
]' \
|
||||||
|
myhome \
|
||||||
|
"Your one-time passcode is 123456"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST / HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
|
||||||
|
{
|
||||||
|
"topic": "myhome",
|
||||||
|
"message": "Your one-time passcode is 123456",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
topic: "myhome",
|
||||||
|
message: "Your one-time passcode is 123456",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: "copy",
|
||||||
|
label: "Copy code",
|
||||||
|
value: "123456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "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": "Your one-time passcode is 123456",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$Request = @{
|
||||||
|
Method = "POST"
|
||||||
|
URI = "https://ntfy.sh"
|
||||||
|
Body = ConvertTo-JSON @{
|
||||||
|
Topic = "myhome"
|
||||||
|
Message = "Your one-time passcode is 123456"
|
||||||
|
Actions = @(
|
||||||
|
@{
|
||||||
|
Action = "copy"
|
||||||
|
Label = "Copy code"
|
||||||
|
Value = "123456"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContentType = "application/json"
|
||||||
|
}
|
||||||
|
Invoke-RestMethod @Request
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/",
|
||||||
|
data=json.dumps({
|
||||||
|
"topic": "myhome",
|
||||||
|
"message": "Your one-time passcode is 123456",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "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": "Your one-time passcode is 123456",
|
||||||
|
"actions": [
|
||||||
|
[
|
||||||
|
"action": "copy",
|
||||||
|
"label": "Copy code",
|
||||||
|
"value": "123456"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
The short format for the `copy` action is `copy, <label>, <value>` (e.g. `copy, Copy code, 123456`),
|
||||||
|
but you can always just use the `<key>=<value>` notation as well (e.g. `action=copy, label=Copy code, value=123456`).
|
||||||
|
|
||||||
|
The `copy` action supports the following fields:
|
||||||
|
|
||||||
|
| Field | Required | Type | Default | Example | Description |
|
||||||
|
|----------|----------|-----------|---------|-----------------|--------------------------------------------------|
|
||||||
|
| `action` | ✔️ | *string* | - | `copy` | Action type (**must be `copy`**) |
|
||||||
|
| `label` | ✔️ | *string* | - | `Copy code` | Label of the action button in the notification |
|
||||||
|
| `value` | ✔️ | *string* | - | `123456` | Value to copy to the clipboard |
|
||||||
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||||
|
|
||||||
## Scheduled delivery
|
## Scheduled delivery
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,45 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
| Component | Version | Release date |
|
| Component | Version | Release date |
|
||||||
|------------------|---------|--------------|
|
|------------------|---------|--------------|
|
||||||
| ntfy server | v2.16.0 | Jan 19, 2026 |
|
| ntfy server | v2.17.0 | Feb 8, 2026 |
|
||||||
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
|
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
|
||||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||||
|
|
||||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||||
|
|
||||||
|
## ntfy server v2.17.0
|
||||||
|
Released February 8, 2026
|
||||||
|
|
||||||
|
This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard,
|
||||||
|
a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several
|
||||||
|
crash fixes, web app improvements, and documentation updates.
|
||||||
|
|
||||||
|
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`),
|
||||||
|
or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
||||||
|
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
|
||||||
|
* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||||
|
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
|
||||||
|
* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
|
||||||
|
* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
|
||||||
|
* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
||||||
|
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||||
|
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
|
||||||
|
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
|
||||||
|
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
|
||||||
|
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
|
||||||
|
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
|
||||||
|
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
||||||
|
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
||||||
|
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
||||||
|
|
||||||
## ntfy Android app v1.22.2
|
## ntfy Android app v1.22.2
|
||||||
Released January 20, 2026
|
Released January 20, 2026
|
||||||
|
|
||||||
@@ -1673,6 +1706,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
|
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
|
||||||
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
|
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
|
||||||
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
|
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
|
||||||
|
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
@@ -1680,25 +1714,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
|
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
|
||||||
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
|
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
|
||||||
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
|
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
|
||||||
|
|
||||||
### ntfy server v2.17.x (UNRELEASED)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
|
||||||
* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
|
||||||
* Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
|
|
||||||
* Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
|
|
||||||
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
|
|
||||||
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
|
|
||||||
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
|
|
||||||
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
|
|
||||||
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
|
|
||||||
* Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
|
||||||
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
|
||||||
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
|
||||||
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
|
||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,12 +21,14 @@ const (
|
|||||||
actionView = "view"
|
actionView = "view"
|
||||||
actionBroadcast = "broadcast"
|
actionBroadcast = "broadcast"
|
||||||
actionHTTP = "http"
|
actionHTTP = "http"
|
||||||
|
actionCopy = "copy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
|
||||||
actionsWithURL = []string{actionView, actionHTTP}
|
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
|
||||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
|
||||||
|
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type actionParser struct {
|
type actionParser struct {
|
||||||
@@ -61,11 +64,13 @@ func parseActions(s string) (actions []*action, err error) {
|
|||||||
}
|
}
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
if !util.Contains(actionsAll, action.Action) {
|
if !util.Contains(actionsAll, action.Action) {
|
||||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
|
||||||
} else if action.Label == "" {
|
} else if action.Label == "" {
|
||||||
return nil, fmt.Errorf("parameter 'label' is required")
|
return nil, fmt.Errorf("parameter 'label' is required")
|
||||||
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||||
|
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
|
||||||
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
|||||||
key = "label"
|
key = "label"
|
||||||
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||||
key = "url"
|
key = "url"
|
||||||
|
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
|
||||||
|
key = "value"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
@@ -188,6 +195,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
|||||||
newAction.Method = value
|
newAction.Method = value
|
||||||
case "body":
|
case "body":
|
||||||
newAction.Body = value
|
newAction.Body = value
|
||||||
|
case "value":
|
||||||
|
newAction.Value = value
|
||||||
case "intent":
|
case "intent":
|
||||||
newAction.Intent = value
|
newAction.Intent = value
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseActions(t *testing.T) {
|
func TestParseActions(t *testing.T) {
|
||||||
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
|
|||||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||||
require.Equal(t, true, actions[1].Clear)
|
require.Equal(t, true, actions[1].Clear)
|
||||||
|
|
||||||
|
// Copy action (simple format)
|
||||||
|
actions, err = parseActions("copy, Copy code, 1234")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "copy", actions[0].Action)
|
||||||
|
require.Equal(t, "Copy code", actions[0].Label)
|
||||||
|
require.Equal(t, "1234", actions[0].Value)
|
||||||
|
|
||||||
|
// Copy action (JSON)
|
||||||
|
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "copy", actions[0].Action)
|
||||||
|
require.Equal(t, "Copy OTP", actions[0].Label)
|
||||||
|
require.Equal(t, "567890", actions[0].Value)
|
||||||
|
|
||||||
|
// Copy action with clear
|
||||||
|
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "copy", actions[0].Action)
|
||||||
|
require.Equal(t, "Copy code", actions[0].Label)
|
||||||
|
require.Equal(t, "1234", actions[0].Value)
|
||||||
|
require.Equal(t, true, actions[0].Clear)
|
||||||
|
|
||||||
|
// Copy action with explicit value key
|
||||||
|
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "copy", actions[0].Action)
|
||||||
|
require.Equal(t, "Copy token", actions[0].Label)
|
||||||
|
require.Equal(t, "abc-123-def", actions[0].Value)
|
||||||
|
require.True(t, actions[0].Clear)
|
||||||
|
|
||||||
|
// Copy action without value (error)
|
||||||
|
_, err = parseActions("copy, Copy code")
|
||||||
|
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||||
|
|
||||||
// Invalid syntax
|
// Invalid syntax
|
||||||
_, 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")
|
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||||
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
|
|||||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||||
|
|
||||||
_, err = parseActions(`fdsfdsf`)
|
_, err = parseActions(`fdsfdsf`)
|
||||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||||
|
|
||||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||||
require.EqualError(t, err, "key 'aaa' unknown")
|
require.EqualError(t, err, "key 'aaa' unknown")
|
||||||
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
|
|||||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||||
|
|
||||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||||
|
|
||||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||||
require.EqualError(t, err, "invalid utf-8 string")
|
require.EqualError(t, err, "invalid utf-8 string")
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ var (
|
|||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
apiHealthPath = "/v1/health"
|
apiHealthPath = "/v1/health"
|
||||||
|
apiVersionPath = "/v1/version"
|
||||||
apiConfigPath = "/v1/config"
|
apiConfigPath = "/v1/config"
|
||||||
apiStatsPath = "/v1/stats"
|
apiStatsPath = "/v1/stats"
|
||||||
apiWebPushPath = "/v1/webpush"
|
apiWebPushPath = "/v1/webpush"
|
||||||
@@ -467,6 +468,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||||
return s.handleHealth(w, r, v)
|
return s.handleHealth(w, r, v)
|
||||||
|
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {
|
||||||
|
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||||
return s.handleConfig(w, r, v)
|
return s.handleConfig(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
@@ -1463,7 +1466,8 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
|||||||
// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
|
// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
|
||||||
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
|
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
|
||||||
// from writing to a response writer that has been cleaned up after the handler returns.
|
// from writing to a response writer that has been cleaned up after the handler returns.
|
||||||
// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
|
// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889
|
||||||
|
// and https://github.com/binwiederhier/ntfy/pull/1598.
|
||||||
wlock.Lock()
|
wlock.Lock()
|
||||||
closed = true
|
closed = true
|
||||||
wlock.Unlock()
|
wlock.Unlock()
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return s.writeJSON(w, &apiVersionResponse{
|
||||||
|
Version: s.config.BuildVersion,
|
||||||
|
Commit: s.config.BuildCommit,
|
||||||
|
Date: s.config.BuildDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
users, err := s.userManager.Users()
|
users, err := s.userManager.Users()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -9,6 +10,41 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestVersion_Admin(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BuildVersion = "1.2.3"
|
||||||
|
c.BuildCommit = "abcdef0"
|
||||||
|
c.BuildDate = "2026-02-08T00:00:00Z"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin and regular user
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
|
// Admin can access /v1/version
|
||||||
|
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
var versionResponse apiVersionResponse
|
||||||
|
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
|
||||||
|
require.Equal(t, "1.2.3", versionResponse.Version)
|
||||||
|
require.Equal(t, "abcdef0", versionResponse.Commit)
|
||||||
|
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
|
||||||
|
|
||||||
|
// Non-admin user cannot access /v1/version
|
||||||
|
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
// Unauthenticated user cannot access /v1/version
|
||||||
|
rr = request(t, s, "GET", "/v1/version", "", nil)
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUser_AddRemove(t *testing.T) {
|
func TestUser_AddRemove(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|||||||
@@ -3901,6 +3901,134 @@ func (m *mockResponseWriter) WriteHeader(statusCode int) {
|
|||||||
m.writeHeaderHit = true
|
m.writeHeaderHit = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// closableResponseWriter simulates a real HTTP response writer that becomes invalid
|
||||||
|
// after the handler returns. In production, Go's HTTP server calls finishRequest() after
|
||||||
|
// the handler returns, which nils out the underlying bufio.Writer. Any subsequent Flush()
|
||||||
|
// from a straggler Publish goroutine causes a nil pointer panic. This mock tracks whether
|
||||||
|
// any Write or Flush occurred after the handler returned (i.e. after Close was called).
|
||||||
|
type closableResponseWriter struct {
|
||||||
|
header http.Header
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
wroteAfterClose atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClosableResponseWriter() *closableResponseWriter {
|
||||||
|
return &closableResponseWriter{
|
||||||
|
header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *closableResponseWriter) Header() http.Header {
|
||||||
|
return w.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *closableResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
w.wroteAfterClose.Store(true)
|
||||||
|
return 0, errors.New("write after handler returned")
|
||||||
|
}
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *closableResponseWriter) WriteHeader(statusCode int) {}
|
||||||
|
|
||||||
|
func (w *closableResponseWriter) Flush() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
w.wroteAfterClose.Store(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close simulates Go's HTTP server cleaning up the response writer after the handler returns.
|
||||||
|
func (w *closableResponseWriter) Close() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) {
|
||||||
|
// This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338:
|
||||||
|
//
|
||||||
|
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||||
|
// bufio.(*Writer).Flush(...)
|
||||||
|
// net/http.(*response).Flush(...)
|
||||||
|
// server.(*Server).handleSubscribeHTTP.func2(...)
|
||||||
|
// server.(*topic).Publish.func1.1(...)
|
||||||
|
//
|
||||||
|
// The race: topic.Publish() copies the subscriber list and calls each subscriber in its own
|
||||||
|
// goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up
|
||||||
|
// the response writer. But a Publish goroutine that copied the subscriber list BEFORE
|
||||||
|
// Unsubscribe may still call sub() AFTER the handler returns.
|
||||||
|
//
|
||||||
|
// This test deterministically reproduces the scenario by:
|
||||||
|
// 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic)
|
||||||
|
// 2. Copying the subscriber function from the topic (simulating what topic.Publish does)
|
||||||
|
// 3. Cancelling the subscription and waiting for the handler to fully return
|
||||||
|
// 4. Calling the copied subscriber function AFTER the handler has returned
|
||||||
|
// 5. Checking that no write/flush occurred on the (now-invalid) response writer
|
||||||
|
//
|
||||||
|
// Without the wlock+closed fix, calling the subscriber after the handler returns writes to
|
||||||
|
// the closed response writer (which in production causes a nil pointer panic on Flush).
|
||||||
|
// With the fix, the subscriber sees closed=true and returns without writing.
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
rw := newClosableResponseWriter()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
req.RemoteAddr = "9.9.9.9:1234"
|
||||||
|
|
||||||
|
// Start the subscribe handler (blocks until context is cancelled)
|
||||||
|
handlerDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.handle(rw, req)
|
||||||
|
close(handlerDone)
|
||||||
|
}()
|
||||||
|
time.Sleep(100 * time.Millisecond) // Wait for subscription to be registered
|
||||||
|
|
||||||
|
// Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does
|
||||||
|
// via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber.
|
||||||
|
s.mu.RLock()
|
||||||
|
tp := s.topics["mytopic"]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
require.NotNil(t, tp)
|
||||||
|
subscribersCopy := tp.subscribersCopy()
|
||||||
|
require.Equal(t, 1, len(subscribersCopy))
|
||||||
|
|
||||||
|
var copiedSub subscriber
|
||||||
|
for _, sub := range subscribersCopy {
|
||||||
|
copiedSub = sub.subscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the subscription and wait for the handler to fully return.
|
||||||
|
// At this point, the deferred cleanup in handleSubscribeHTTP runs:
|
||||||
|
// - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock()
|
||||||
|
// - Without fix: nothing prevents future sub() calls from writing
|
||||||
|
cancel()
|
||||||
|
<-handlerDone
|
||||||
|
|
||||||
|
// Simulate Go's HTTP server cleaning up the response writer after the handler returns.
|
||||||
|
// In production, this is finishRequest() which nils out the bufio.Writer.
|
||||||
|
rw.Close()
|
||||||
|
|
||||||
|
// Now call the copied subscriber function, simulating a straggler Publish goroutine
|
||||||
|
// that copied the subscriber list before Unsubscribe ran. In production, this is exactly
|
||||||
|
// how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the
|
||||||
|
// handler has already returned and Go has cleaned up the response writer.
|
||||||
|
v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil)
|
||||||
|
msg := newDefaultMessage("mytopic", "straggler message")
|
||||||
|
_ = copiedSub(v, msg)
|
||||||
|
|
||||||
|
require.False(t, rw.wroteAfterClose.Load(),
|
||||||
|
"sub() wrote to the response writer after the handler returned; "+
|
||||||
|
"in production this causes a nil pointer panic in bufio.(*Writer).Flush()")
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
|
func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
|
||||||
// Test that handleError does not call WriteHeader for WebSocket errors wrapped
|
// Test that handleError does not call WriteHeader for WebSocket errors wrapped
|
||||||
// with errWebSocketPostUpgrade (indicating the connection was hijacked)
|
// with errWebSocketPostUpgrade (indicating the connection was hijacked)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ type attachment struct {
|
|||||||
|
|
||||||
type action struct {
|
type action struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Action string `json:"action"` // "view", "broadcast", or "http"
|
Action string `json:"action"` // "view", "broadcast", "http", or "copy"
|
||||||
Label string `json:"label"` // action button label
|
Label string `json:"label"` // action button label
|
||||||
Clear bool `json:"clear"` // clear notification after successful execution
|
Clear bool `json:"clear"` // clear notification after successful execution
|
||||||
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||||
@@ -95,6 +95,7 @@ type action struct {
|
|||||||
Body string `json:"body,omitempty"` // used in "http" action
|
Body string `json:"body,omitempty"` // used in "http" action
|
||||||
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||||
|
Value string `json:"value,omitempty"` // used in "copy" action
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAction() *action {
|
func newAction() *action {
|
||||||
@@ -319,6 +320,12 @@ type apiHealthResponse struct {
|
|||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiVersionResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiStatsResponse struct {
|
type apiStatsResponse struct {
|
||||||
Messages int64 `json:"messages"`
|
Messages int64 `json:"messages"`
|
||||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
|
|||||||
import { NetworkFirst } from "workbox-strategies";
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
import { clientsClaim } from "workbox-core";
|
import { clientsClaim } from "workbox-core";
|
||||||
import { dbAsync } from "../src/app/db";
|
import { dbAsync } from "../src/app/db";
|
||||||
|
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
|
||||||
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||||
import initI18n from "../src/app/i18n";
|
import initI18n from "../src/app/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -250,12 +251,12 @@ const handleClick = async (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (action.action === "view") {
|
if (action.action === ACTION_VIEW) {
|
||||||
self.clients.openWindow(action.url);
|
self.clients.openWindow(action.url);
|
||||||
if (action.clear) {
|
if (action.clear) {
|
||||||
await clearNotification();
|
await clearNotification();
|
||||||
}
|
}
|
||||||
} else if (action.action === "http") {
|
} else if (action.action === ACTION_HTTP) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(action.url, {
|
const response = await fetch(action.url, {
|
||||||
method: action.method ?? "POST",
|
method: action.method ?? "POST",
|
||||||
|
|||||||
7
web/src/app/actions.js
Normal file
7
web/src/app/actions.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Action types for ntfy messages
|
||||||
|
// These correspond to the server action types in server/actions.go
|
||||||
|
|
||||||
|
export const ACTION_VIEW = "view";
|
||||||
|
export const ACTION_BROADCAST = "broadcast";
|
||||||
|
export const ACTION_HTTP = "http";
|
||||||
|
export const ACTION_COPY = "copy";
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// and cannot be used in the service worker
|
// and cannot be used in the service worker
|
||||||
|
|
||||||
import emojisMapped from "./emojisMapped";
|
import emojisMapped from "./emojisMapped";
|
||||||
|
import { ACTION_HTTP, ACTION_VIEW } from "./actions";
|
||||||
|
|
||||||
const toEmojis = (tags) => {
|
const toEmojis = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
@@ -81,7 +82,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUr
|
|||||||
topicRoute,
|
topicRoute,
|
||||||
},
|
},
|
||||||
actions: message.actions
|
actions: message.actions
|
||||||
?.filter(({ action }) => action === "view" || action === "http")
|
?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP)
|
||||||
.map(({ label }) => ({
|
.map(({ label }) => ({
|
||||||
action: label,
|
action: label,
|
||||||
title: label,
|
title: label,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
topicUrl,
|
topicUrl,
|
||||||
unmatchedTags,
|
unmatchedTags,
|
||||||
} from "../app/utils";
|
} from "../app/utils";
|
||||||
|
import { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../app/actions";
|
||||||
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
||||||
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
@@ -345,7 +346,7 @@ const NotificationItem = (props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasUserActions && <UserActions notification={notification} />}
|
{hasUserActions && <UserActions notification={notification} onShowSnack={props.onShowSnack} />}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -487,7 +488,7 @@ const Image = (props) => {
|
|||||||
const UserActions = (props) => (
|
const UserActions = (props) => (
|
||||||
<>
|
<>
|
||||||
{props.notification.actions.map((action) => (
|
{props.notification.actions.map((action) => (
|
||||||
<UserAction key={action.id} notification={props.notification} action={action} />
|
<UserAction key={action.id} notification={props.notification} action={action} onShowSnack={props.onShowSnack} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -549,7 +550,7 @@ const UserAction = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { notification } = props;
|
const { notification } = props;
|
||||||
const { action } = props;
|
const { action } = props;
|
||||||
if (action.action === "broadcast") {
|
if (action.action === ACTION_BROADCAST) {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||||
<span>
|
<span>
|
||||||
@@ -560,7 +561,7 @@ const UserAction = (props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (action.action === "view") {
|
if (action.action === ACTION_VIEW) {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
openUrl(action.url);
|
openUrl(action.url);
|
||||||
if (action.clear) {
|
if (action.clear) {
|
||||||
@@ -580,7 +581,7 @@ const UserAction = (props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (action.action === "http") {
|
if (action.action === ACTION_HTTP) {
|
||||||
const method = action.method ?? "POST";
|
const method = action.method ?? "POST";
|
||||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||||
return (
|
return (
|
||||||
@@ -602,6 +603,22 @@ const UserAction = (props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (action.action === ACTION_COPY) {
|
||||||
|
const handleClick = async () => {
|
||||||
|
await copyToClipboard(action.value);
|
||||||
|
props.onShowSnack();
|
||||||
|
if (action.clear) {
|
||||||
|
await clearNotification(notification);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||||
|
<Button onClick={handleClick} aria-label={t("common_copy_to_clipboard")}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null; // Others
|
return null; // Others
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user