Compare commits

...

24 Commits

Author SHA1 Message Date
binwiederhier
4b474a89b7 Docs 2026-01-19 18:29:45 -05:00
binwiederhier
5ba1c71140 Fix grouping issue with sequence ID 2026-01-18 21:30:12 -05:00
binwiederhier
de81865c27 Bump deps 2026-01-18 20:11:48 -05:00
binwiederhier
ed9c1bcb78 Wording change 2026-01-18 19:46:14 -05:00
binwiederhier
190d12cd54 Release banner 2026-01-18 19:41:34 -05:00
Philipp C. Heckel
63bf82e915 Merge pull request #1556 from binwiederhier/cancel-scheduled
Updated/cancel scheduled messages
2026-01-18 19:38:52 -05:00
binwiederhier
014b7355c5 Re-org 2026-01-18 19:24:01 -05:00
binwiederhier
602f201bae Derp 2026-01-18 19:15:10 -05:00
binwiederhier
2739d8a325 Re-organize docs 2026-01-18 19:09:14 -05:00
binwiederhier
b8e01fde33 Docs 2026-01-18 16:22:06 -05:00
binwiederhier
9ecf21c65a Docs 2026-01-18 16:16:04 -05:00
binwiederhier
ac9cfbfaf4 Delete attachments 2026-01-18 16:04:42 -05:00
binwiederhier
c23d201186 Updated/cancel scheduled messages 2026-01-18 15:50:40 -05:00
binwiederhier
86157fc7f6 Lint 2026-01-18 11:14:07 -05:00
binwiederhier
279c164bf5 Fix build 2026-01-18 11:13:56 -05:00
binwiederhier
743b00e59c Merge branch 'main' of github.com:binwiederhier/ntfy 2026-01-18 10:57:10 -05:00
binwiederhier
eddf654b96 Last fixes 2026-01-18 10:56:11 -05:00
binwiederhier
886be722bc Release notes 2026-01-18 10:52:27 -05:00
binwiederhier
6886ca24b1 Self-review 2026-01-18 10:51:36 -05:00
binwiederhier
856f150958 Better 2026-01-18 10:46:15 -05:00
binwiederhier
5a1aa68ead Refine 2026-01-18 09:44:21 -05:00
binwiederhier
cc9f9c0d24 Update checker 2026-01-17 20:36:15 -05:00
Philipp C. Heckel
8deb2df88d Update Windows support details in releases.md 2026-01-17 20:26:37 -05:00
Philipp C. Heckel
603273ab9d Merge pull request #1552 from binwiederhier/windows-server
Support "ntfy serve" on Windows
2026-01-17 18:12:37 -05:00
23 changed files with 2191 additions and 1428 deletions

View File

@@ -3,11 +3,12 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"regexp"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"os"
"regexp"
) )
const ( const (
@@ -15,6 +16,12 @@ const (
categoryServer = "Server commands" categoryServer = "Server commands"
) )
// Build metadata keys for app.Metadata
const (
MetadataKeyCommit = "commit"
MetadataKeyDate = "date"
)
var commands = make([]*cli.Command, 0) var commands = make([]*cli.Command, 0)
var flagsDefault = []cli.Flag{ var flagsDefault = []cli.Flag{

View File

@@ -501,7 +501,9 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version conf.BuildVersion = c.App.Version
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
// Check if we should run as a Windows service // Check if we should run as a Windows service
if ranAsService, err := maybeRunAsService(conf); err != nil { if ranAsService, err := maybeRunAsService(conf); err != nil {
@@ -655,3 +657,18 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
} }
return tokens, nil return tokens, nil
} }
func maybeFromMetadata(m map[string]any, key string) string {
if m == nil {
return ""
}
v, exists := m[key]
if !exists {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -201,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## 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.15.0/ntfy_2.15.0_darwin_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz 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
tar zxvf ntfy_2.15.0_darwin_all.tar.gz tar zxvf ntfy_2.16.0_darwin_all.tar.gz
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help ntfy --help
``` ```
@@ -231,7 +231,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.15.0/ntfy_2.15.0_windows_amd64.zip), * [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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`

File diff suppressed because one or more lines are too long

View File

@@ -6,12 +6,34 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date | | Component | Version | Release date |
|------------------|---------|--------------| |------------------|---------|--------------|
| ntfy server | v2.15.0 | Nov 16, 2025 | | ntfy server | v2.16.0 | Jan 19, 2026 |
| ntfy Android app | v1.21.1 | Jan 6, 2025 | | ntfy Android app | v1.21.1 | Jan 6, 2025 |
| 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.16.0
Released January 19, 2026
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
This one is very exciting, as it brings a lot of highly requested features to ntfy.
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
thanks to [@wtf911](https://github.com/wtf911))
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
## ntfy Android app v1.21.1 ## ntfy Android app v1.21.1
Released January 6, 2026 Released January 6, 2026
@@ -1599,15 +1621,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.16.x (UNRELEASED)
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
### ntfy Android app v1.22.x (UNRELEASED) ### ntfy Android app v1.22.x (UNRELEASED)
**Features:** **Features:**

19
main.go
View File

@@ -2,12 +2,14 @@ package main
import ( import (
"fmt" "fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
"os" "os"
"runtime" "runtime"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
) )
// These variables are set during build time using -ldflags
var ( var (
version = "dev" version = "dev"
commit = "unknown" commit = "unknown"
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
ntfy %s (%s), runtime %s, built at %s ntfy %s (%s), runtime %s, built at %s
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2 Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
`, version, commit[:7], runtime.Version(), date) `, version, maybeShortCommit(commit), runtime.Version(), date)
app := cmd.New() app := cmd.New()
app.Version = version app.Version = version
app.Metadata = map[string]any{
cmd.MetadataKeyDate: date,
cmd.MetadataKeyCommit: commit,
}
if err := app.Run(os.Args); err != nil { if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err.Error()) fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1) os.Exit(1)
} }
} }
func maybeShortCommit(commit string) string {
if len(commit) > 7 {
return commit[:7]
}
return commit
}

View File

@@ -1,8 +1,12 @@
package server package server
import ( import (
"crypto/sha256"
"encoding/json"
"fmt"
"io/fs" "io/fs"
"net/netip" "net/netip"
"reflect"
"text/template" "text/template"
"time" "time"
@@ -179,7 +183,9 @@ type Config struct {
WebPushStartupQueries string WebPushStartupQueries string
WebPushExpiryDuration time.Duration WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration WebPushExpiryWarningDuration time.Duration
Version string // injected by App BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
} }
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
@@ -266,12 +272,32 @@ func NewConfig() *Config {
EnableReservations: false, EnableReservations: false,
RequireLogin: false, RequireLogin: false,
AccessControlAllowOrigin: "*", AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "", WebPushPrivateKey: "",
WebPushPublicKey: "", WebPushPublicKey: "",
WebPushFile: "", WebPushFile: "",
WebPushEmailAddress: "", WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "",
BuildDate: "",
BuildCommit: "",
} }
} }
// Hash computes an SHA-256 hash of the configuration. This is used to detect
// configuration changes for the web app version check feature. It uses reflection
// to include all JSON-serializable fields automatically.
func (c *Config) Hash() string {
v := reflect.ValueOf(*c)
t := v.Type()
var result string
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := t.Field(i).Name
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
if b, err := json.Marshal(field.Interface()); err == nil {
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
}
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
}

View File

@@ -72,10 +72,12 @@ const (
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
selectMessagesByIDQuery = ` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByIDQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages FROM messages
WHERE mid = ? WHERE mid = ?
@@ -607,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
return tx.Commit() return tx.Commit()
} }
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// First, get the message IDs of scheduled messages to be deleted
rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close() // Close rows before executing delete in same transaction
// Then delete the messages
if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return ids, nil
}
func (c *messageCache) ExpireMessages(topics ...string) error { func (c *messageCache) ExpireMessages(topics ...string) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()

View File

@@ -703,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
require.Equal(t, messages[1].Sender, netip.Addr{}) require.Equal(t, messages[1].Sender, netip.Addr{})
} }
func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
}
func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
testDeleteScheduledBySequenceID(t, newMemTestCache(t))
}
func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
// Create a scheduled (unpublished) message
scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
scheduledMsg.ID = "scheduled1"
scheduledMsg.SequenceID = "seq123"
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
require.Nil(t, c.AddMessage(scheduledMsg))
// Create a published message with different sequence ID
publishedMsg := newDefaultMessage("mytopic", "published message")
publishedMsg.ID = "published1"
publishedMsg.SequenceID = "seq456"
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
require.Nil(t, c.AddMessage(publishedMsg))
// Create a scheduled message in a different topic
otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
otherTopicMsg.ID = "other1"
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(otherTopicMsg))
// Verify all messages exist (including scheduled)
messages, err := c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
messages, err = c.Messages("othertopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
// Delete scheduled message by sequence ID and verify returned IDs
deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
require.Nil(t, err)
require.Equal(t, 1, len(deletedIDs))
require.Equal(t, "scheduled1", deletedIDs[0])
// Verify scheduled message is deleted
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
// Verify other topic's message still exists (topic-scoped deletion)
messages, err = c.Messages("othertopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "other scheduled", messages[0].Message)
// Deleting non-existent sequence ID should return empty list
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
require.Nil(t, err)
require.Empty(t, deletedIDs)
// Deleting published message should not affect it (only deletes unpublished)
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
require.Nil(t, err)
require.Empty(t, deletedIDs)
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
}
func checkSchemaVersion(t *testing.T, db *sql.DB) { func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`) rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err) require.Nil(t, err)

View File

@@ -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"
apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats" apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush" apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers" apiTiersPath = "/v1/tiers"
@@ -277,9 +278,9 @@ func (s *Server) Run() error {
if s.config.ProfileListenHTTP != "" { if s.config.ProfileListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP) listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
} }
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
if log.IsFile() { if log.IsFile() {
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File()) fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -460,6 +461,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 == apiConfigPath {
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 {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
@@ -600,8 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
return s.writeJSON(w, response) return s.writeJSON(w, response)
} }
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Cache-Control", "no-cache")
return s.writeJSON(w, s.configResponse())
}
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &apiConfigResponse{ b, err := json.MarshalIndent(s.configResponse(), "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
}
func (s *Server) configResponse() *apiConfigResponse {
return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot, AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
@@ -615,15 +634,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BillingContact: s.config.BillingContact, BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey, WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics, DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(),
} }
b, err := json.MarshalIndent(response, "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
} }
// handleWebManifest serves the web app manifest for the progressive web app (PWA) // handleWebManifest serves the web app manifest for the progressive web app (PWA)
@@ -851,6 +863,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later") logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
} }
if cache { if cache {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
if err != nil {
return nil, err
}
// Delete attachment files for deleted scheduled messages
if s.fileCache != nil && len(deletedIDs) > 0 {
if err := s.fileCache.Remove(deletedIDs...); err != nil {
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
}
}
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache") logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
if err := s.messageCache.AddMessage(m); err != nil { if err := s.messageCache.AddMessage(m); err != nil {
return nil, err return nil, err
@@ -946,6 +969,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
if s.config.WebPushPublicKey != "" { if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m) go s.publishToWebPushEndpoints(v, m)
} }
if event == messageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
if err != nil {
return err
}
// Delete attachment files for deleted scheduled messages
if s.fileCache != nil && len(deletedIDs) > 0 {
if err := s.fileCache.Remove(deletedIDs...); err != nil {
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
}
}
}
// Add to message cache // Add to message cache
if err := s.messageCache.AddMessage(m); err != nil { if err := s.messageCache.AddMessage(m); err != nil {
return err return err
@@ -991,7 +1027,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
logvm(v, m).Err(err).Warn("Unable to publish poll request") logvm(v, m).Err(err).Warn("Unable to publish poll request")
return return
} }
req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("X-Poll-ID", m.ID) req.Header.Set("X-Poll-ID", m.ID)
if s.config.UpstreamAccessToken != "" { if s.config.UpstreamAccessToken != "" {
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))

View File

@@ -3495,6 +3495,162 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) {
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
} }
func TestServer_UpdateScheduledMessage(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a scheduled message (future delivery)
response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil)
require.Equal(t, 200, response.Code)
msg1 := toMessage(t, response.Body.String())
require.Equal(t, "sched-seq", msg1.SequenceID)
require.Equal(t, "original scheduled message", msg1.Message)
// Verify scheduled message exists
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "original scheduled message", messages[0].Message)
// Update the scheduled message (same sequence ID, new content)
response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil)
require.Equal(t, 200, response.Code)
msg2 := toMessage(t, response.Body.String())
require.Equal(t, "sched-seq", msg2.SequenceID)
require.Equal(t, "updated scheduled message", msg2.Message)
require.NotEqual(t, msg1.ID, msg2.ID)
// Verify only the updated message exists (old scheduled was deleted)
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "updated scheduled message", messages[0].Message)
require.Equal(t, msg2.ID, messages[0].ID)
}
func TestServer_DeleteScheduledMessage(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a scheduled message (future delivery)
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "delete-sched-seq", msg.SequenceID)
// Verify scheduled message exists
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "scheduled message to delete", messages[0].Message)
// Delete the scheduled message
response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil)
require.Equal(t, 200, response.Code)
deleteMsg := toMessage(t, response.Body.String())
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
require.Equal(t, "message_delete", deleteMsg.Event)
// Verify scheduled message was deleted, only delete event remains
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "message_delete", messages[0].Event)
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
}
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish scheduled messages with same sequence ID in different topics
response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil)
require.Equal(t, 200, response.Code)
// Update scheduled message in topic1 only
response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil)
require.Equal(t, 200, response.Code)
// Verify topic1 has only the updated message
response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "topic1 updated", messages[0].Message)
// Verify topic2 still has its original scheduled message (not affected)
response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "topic2 scheduled", messages[0].Message)
}
func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a scheduled message with an attachment
content := util.RandomString(5000) // > 4096 to trigger attachment
response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil)
require.Equal(t, 200, response.Code)
msg1 := toMessage(t, response.Body.String())
require.Equal(t, "attach-seq", msg1.SequenceID)
require.NotNil(t, msg1.Attachment)
// Verify attachment file exists
attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)
require.FileExists(t, attachmentFile1)
// Update the scheduled message with a new attachment
newContent := util.RandomString(5000)
response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil)
require.Equal(t, 200, response.Code)
msg2 := toMessage(t, response.Body.String())
require.Equal(t, "attach-seq", msg2.SequenceID)
require.NotEqual(t, msg1.ID, msg2.ID)
// Verify old attachment file was deleted
require.NoFileExists(t, attachmentFile1)
// Verify new attachment file exists
attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)
require.FileExists(t, attachmentFile2)
}
func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a scheduled message with an attachment
content := util.RandomString(5000) // > 4096 to trigger attachment
response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "delete-attach-seq", msg.SequenceID)
require.NotNil(t, msg.Attachment)
// Verify attachment file exists
attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
require.FileExists(t, attachmentFile)
// Delete the scheduled message
response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil)
require.Equal(t, 200, response.Code)
deleteMsg := toMessage(t, response.Body.String())
require.Equal(t, "message_delete", deleteMsg.Event)
// Verify attachment file was deleted
require.NoFileExists(t, attachmentFile)
}
func newTestConfig(t *testing.T) *Config { func newTestConfig(t *testing.T) *Config {
conf := NewConfig() conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345" conf.BaseURL = "http://127.0.0.1:12345"

View File

@@ -125,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)

View File

@@ -482,6 +482,7 @@ type apiConfigResponse struct {
BillingContact string `json:"billing_contact"` BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"` WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"` DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"`
} }
type apiAccountBillingPrices struct { type apiAccountBillingPrices struct {

6
web/package-lock.json generated
View File

@@ -3823,9 +3823,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001764", "version": "1.0.30001765",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@@ -19,4 +19,5 @@ var config = {
billing_contact: "", billing_contact: "",
web_push_public_key: "", web_push_public_key: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
config_hash: "dev", // Placeholder for development; actual value is generated server-side
}; };

View File

@@ -4,6 +4,9 @@
"common_add": "Add", "common_add": "Add",
"common_back": "Back", "common_back": "Back",
"common_copy_to_clipboard": "Copy to clipboard", "common_copy_to_clipboard": "Copy to clipboard",
"common_refresh": "Refresh",
"version_update_available_title": "New version available",
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
"signup_title": "Create a ntfy account", "signup_title": "Create a ntfy account",
"signup_form_username": "Username", "signup_form_username": "Username",
"signup_form_password": "Password", "signup_form_password": "Password",

View File

@@ -4,7 +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 { badge, icon, messageWithSequenceId, 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 {
EVENT_MESSAGE, EVENT_MESSAGE,
@@ -38,6 +38,13 @@ const handlePushMessage = async (data) => {
console.log("[ServiceWorker] Message received", data); console.log("[ServiceWorker] Message received", data);
// Look up subscription for baseUrl and topic
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
return;
}
// Delete existing notification with same sequence ID (if any) // Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id; const sequenceId = message.sequence_id || message.id;
if (sequenceId) { if (sequenceId) {
@@ -65,10 +72,11 @@ const handlePushMessage = async (data) => {
await self.registration.showNotification( await self.registration.showNotification(
...toNotificationParams({ ...toNotificationParams({
subscriptionId,
message, message,
defaultTitle: message.topic, defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(), topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
}) })
); );
}; };
@@ -81,18 +89,23 @@ const handlePushMessageDelete = async (data) => {
const db = await dbAsync(); const db = await dbAsync();
console.log("[ServiceWorker] Deleting notification sequence", data); console.log("[ServiceWorker] Deleting notification sequence", data);
// Look up subscription for baseUrl and topic
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
return;
}
// Delete notification with the same sequence_id // Delete notification with the same sequence_id
const sequenceId = message.sequence_id; const sequenceId = message.sequence_id;
if (sequenceId) { if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete(); await db.notifications.where({ subscriptionId, sequenceId }).delete();
} }
// Close browser notification with matching tag // Close browser notification with matching tag (scoped by topic)
const tag = message.sequence_id || message.id; const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
if (tag) { const notifications = await self.registration.getNotifications({ tag });
const notifications = await self.registration.getNotifications({ tag }); notifications.forEach((notification) => notification.close());
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries) // Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
@@ -108,18 +121,23 @@ const handlePushMessageClear = async (data) => {
const db = await dbAsync(); const db = await dbAsync();
console.log("[ServiceWorker] Marking notification as read", data); console.log("[ServiceWorker] Marking notification as read", data);
// Look up subscription for baseUrl and topic
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
return;
}
// Mark notification as read (set new = 0) // Mark notification as read (set new = 0)
const sequenceId = message.sequence_id; const sequenceId = message.sequence_id;
if (sequenceId) { if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
} }
// Close browser notification with matching tag // Close browser notification with matching tag (scoped by topic)
const tag = message.sequence_id || message.id; const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
if (tag) { const notifications = await self.registration.getNotifications({ tag });
const notifications = await self.registration.getNotifications({ tag }); notifications.forEach((notification) => notification.close());
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries) // Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {

View File

@@ -1,5 +1,5 @@
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
import { toNotificationParams } from "./notificationUtils"; import { notificationTag, toNotificationParams } from "./notificationUtils";
import prefs from "./Prefs"; import prefs from "./Prefs";
import routes from "../components/routes"; import routes from "../components/routes";
@@ -23,21 +23,23 @@ class Notifier {
const registration = await this.serviceWorkerRegistration(); const registration = await this.serviceWorkerRegistration();
await registration.showNotification( await registration.showNotification(
...toNotificationParams({ ...toNotificationParams({
subscriptionId: subscription.id,
message: notification, message: notification,
defaultTitle, defaultTitle,
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
}) })
); );
} }
async cancel(notification) { async cancel(subscription, notification) {
if (!this.supported()) { if (!this.supported()) {
return; return;
} }
try { try {
const tag = notification.sequence_id || notification.id; const sequenceId = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${tag}`); const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
const registration = await this.serviceWorkerRegistration(); const registration = await this.serviceWorkerRegistration();
const notifications = await registration.getNotifications({ tag }); const notifications = await registration.getNotifications({ tag });
notifications.forEach((n) => n.close()); notifications.forEach((n) => n.close());

View File

@@ -19,7 +19,11 @@ class Pruner {
} }
stopWorker() { stopWorker() {
clearTimeout(this.timer); if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
console.log("[Pruner] Stopped worker");
} }
async prune() { async prune() {

View File

@@ -0,0 +1,72 @@
/**
* VersionChecker polls the /v1/config endpoint to detect new server versions
* or configuration changes, prompting users to refresh the page.
*/
const intervalMillis = 5 * 60 * 1000; // 5 minutes
class VersionChecker {
constructor() {
this.initialConfigHash = null;
this.listener = null;
this.timer = null;
}
/**
* Starts the version checker worker. It stores the initial config hash
* from the config.js and polls the server every 5 minutes.
*/
startWorker() {
// Store initial config hash from the config loaded at page load
this.initialConfigHash = window.config?.config_hash || "";
console.log("[VersionChecker] Starting version checker");
this.timer = setInterval(() => this.checkVersion(), intervalMillis);
}
stopWorker() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
console.log("[VersionChecker] Stopped version checker");
}
registerListener(listener) {
this.listener = listener;
}
resetListener() {
this.listener = null;
}
async checkVersion() {
if (!this.initialConfigHash) {
return;
}
try {
const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
if (!response.ok) {
console.log("[VersionChecker] Failed to fetch config:", response.status);
return;
}
const data = await response.json();
const currentHash = data.config_hash;
if (currentHash && currentHash !== this.initialConfigHash) {
console.log("[VersionChecker] Version or config changed, showing banner");
if (this.listener) {
this.listener();
}
} else {
console.log("[VersionChecker] No version change detected");
}
} catch (error) {
console.log("[VersionChecker] Error checking config:", error);
}
}
}
const versionChecker = new VersionChecker();
export default versionChecker;

View File

@@ -50,8 +50,16 @@ export const isImage = (attachment) => {
export const icon = "/static/images/ntfy.png"; export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg"; export const badge = "/static/images/mask-icon.svg";
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { /**
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
* This ensures notifications from different topics with the same sequence ID don't collide.
*/
export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;
export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {
const image = isImage(message.attachment) ? message.attachment.url : undefined; const image = isImage(message.attachment) ? message.attachment.url : undefined;
const sequenceId = message.sequence_id || message.id;
const tag = notificationTag(baseUrl, topic, sequenceId);
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
return [ return [
@@ -62,7 +70,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
icon, icon,
image, image,
timestamp: message.time * 1000, timestamp: message.time * 1000,
tag: message.sequence_id || message.id, // Update notification if there is a sequence ID tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions
renotify: true, renotify: true,
silent: false, silent: false,
// This is used by the notification onclick event // This is used by the notification onclick event

View File

@@ -1,23 +1,23 @@
import { import {
Drawer,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Divider,
List,
Alert, Alert,
AlertTitle, AlertTitle,
Badge, Badge,
Box,
Button,
CircularProgress, CircularProgress,
Divider,
Drawer,
IconButton,
Link, Link,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader, ListSubheader,
Portal, Portal,
Toolbar,
Tooltip, Tooltip,
Typography, Typography,
Box,
IconButton,
Button,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import * as React from "react"; import * as React from "react";
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
import { useNotificationPermissionListener } from "./hooks"; import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
const navWidth = 280; const navWidth = 280;
@@ -91,6 +91,13 @@ const NavList = (props) => {
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const [versionChanged, setVersionChanged] = useState(false);
const handleVersionChange = () => {
setVersionChanged(true);
};
useVersionChangeListener(handleVersionChange);
const handleSubscribeReset = () => { const handleSubscribeReset = () => {
setSubscribeDialogOpen(false); setSubscribeDialogOpen(false);
@@ -119,6 +126,7 @@ const NavList = (props) => {
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const alertVisible = const alertVisible =
versionChanged ||
showNotificationPermissionRequired || showNotificationPermissionRequired ||
showNotificationPermissionDenied || showNotificationPermissionDenied ||
showNotificationIOSInstallRequired || showNotificationIOSInstallRequired ||
@@ -129,6 +137,7 @@ const NavList = (props) => {
<> <>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} /> <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}> <List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
{versionChanged && <VersionUpdateBanner />}
{showNotificationPermissionRequired && <NotificationPermissionRequired />} {showNotificationPermissionRequired && <NotificationPermissionRequired />}
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />} {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />} {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
@@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => {
); );
}; };
const VersionUpdateBanner = () => {
const { t } = useTranslation();
const handleRefresh = () => {
window.location.reload();
};
return (
<Alert severity="info" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("version_update_available_title")}</AlertTitle>
<Typography gutterBottom>{t("version_update_available_description")}</Typography>
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={handleRefresh}>
{t("common_refresh")}
</Button>
</Alert>
);
};
export default Navigation; export default Navigation;

View File

@@ -9,6 +9,7 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner"; import pruner from "../app/Pruner";
import session from "../app/Session"; import session from "../app/Session";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import versionChecker from "../app/VersionChecker";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import prefs from "../app/Prefs"; import prefs from "../app/Prefs";
@@ -50,7 +51,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
} }
}; };
const handleNotification = async (subscriptionId, notification) => { const handleNotification = async (subscription, notification) => {
// This logic is (partially) duplicated in // This logic is (partially) duplicated in
// - Android: SubscriberService::onNotificationReceived() // - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived() // - Android: FirebaseService::onMessageReceived()
@@ -58,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(notification); await notifier.cancel(subscription, notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(notification); await notifier.cancel(subscription, notification);
} else { } else {
// Regular message: delete existing and add new // Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id; const sequenceId = notification.sequence_id || notification.id;
if (sequenceId) { if (sequenceId) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);
} }
const added = await subscriptionManager.addNotification(subscriptionId, notification); const added = await subscriptionManager.addNotification(subscription.id, notification);
if (added) { if (added) {
await subscriptionManager.notify(subscriptionId, notification); await subscriptionManager.notify(subscription.id, notification);
} }
} }
}; };
@@ -88,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
if (subscription.internal) { if (subscription.internal) {
await handleInternalMessage(message); await handleInternalMessage(message);
} else { } else {
await handleNotification(subscriptionId, message); await handleNotification(subscription, message);
} }
}; };
@@ -292,12 +293,14 @@ const startWorkers = () => {
poller.startWorker(); poller.startWorker();
pruner.startWorker(); pruner.startWorker();
accountApi.startWorker(); accountApi.startWorker();
versionChecker.startWorker();
}; };
const stopWorkers = () => { const stopWorkers = () => {
poller.stopWorker(); poller.stopWorker();
pruner.stopWorker(); pruner.stopWorker();
accountApi.stopWorker(); accountApi.stopWorker();
versionChecker.stopWorker();
}; };
export const useBackgroundProcesses = () => { export const useBackgroundProcesses = () => {
@@ -323,3 +326,15 @@ export const useAccountListener = (setAccount) => {
}; };
}, []); }, []);
}; };
/**
* Hook to detect version/config changes and call the provided callback when a change is detected.
*/
export const useVersionChangeListener = (onVersionChange) => {
useEffect(() => {
versionChecker.registerListener(onVersionChange);
return () => {
versionChecker.resetListener();
};
}, [onVersionChange]);
};