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

View File

@@ -501,7 +501,9 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
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
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
}
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"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.15.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
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.16.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 ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.16.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 ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.16.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 ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.16.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 ntfy serve
```
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,28 +150,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```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 start ntfy
```
=== "armv6"
```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 start ntfy
```
=== "armv7/armhf"
```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 start ntfy
```
=== "arm64"
```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 start ntfy
```
@@ -201,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
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`).
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).
```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
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
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.16.0_darwin_all.tar.gz
sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/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
```
@@ -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.
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%`.
* 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 |
|------------------|---------|--------------|
| 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 iOS app | v1.3 | Nov 26, 2023 |
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
Released January 6, 2026
@@ -1599,15 +1621,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## 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)
**Features:**

19
main.go
View File

@@ -2,12 +2,14 @@ package main
import (
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
"os"
"runtime"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
)
// These variables are set during build time using -ldflags
var (
version = "dev"
commit = "unknown"
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
ntfy %s (%s), runtime %s, built at %s
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.Version = version
app.Metadata = map[string]any{
cmd.MetadataKeyDate: date,
cmd.MetadataKeyCommit: commit,
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
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
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io/fs"
"net/netip"
"reflect"
"text/template"
"time"
@@ -179,7 +183,9 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration 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
@@ -266,12 +272,32 @@ func NewConfig() *Config {
EnableReservations: false,
RequireLogin: false,
AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushFile: "",
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
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 = `
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
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
FROM messages
WHERE mid = ?
@@ -607,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
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 {
c.mu.Lock()
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{})
}
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) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)

View File

@@ -90,6 +90,7 @@ var (
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
@@ -277,9 +278,9 @@ func (s *Server) Run() error {
if 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() {
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())
}
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)
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
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 {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} 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)
}
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 {
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
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
@@ -615,15 +634,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
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)
@@ -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")
}
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")
if err := s.messageCache.AddMessage(m); err != nil {
return nil, err
@@ -946,6 +969,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
if s.config.WebPushPublicKey != "" {
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
if err := s.messageCache.AddMessage(m); err != nil {
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")
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)
if 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"])
}
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 {
conf := NewConfig()
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 {
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.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
if err != nil {
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.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
if err != nil {
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.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)

View File

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

6
web/package-lock.json generated
View File

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

View File

@@ -19,4 +19,5 @@ var config = {
billing_contact: "",
web_push_public_key: "",
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_back": "Back",
"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_form_username": "Username",
"signup_form_password": "Password",

View File

@@ -4,7 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
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 {
EVENT_MESSAGE,
@@ -38,6 +38,13 @@ const handlePushMessage = async (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)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
@@ -65,10 +72,11 @@ const handlePushMessage = async (data) => {
await self.registration.showNotification(
...toNotificationParams({
subscriptionId,
message,
defaultTitle: message.topic,
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();
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
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete();
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Close browser notification with matching tag (scoped by topic)
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {
@@ -108,18 +121,23 @@ const handlePushMessageClear = async (data) => {
const db = await dbAsync();
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)
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Close browser notification with matching tag (scoped by topic)
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {

View File

@@ -1,5 +1,5 @@
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
import { toNotificationParams } from "./notificationUtils";
import { notificationTag, toNotificationParams } from "./notificationUtils";
import prefs from "./Prefs";
import routes from "../components/routes";
@@ -23,21 +23,23 @@ class Notifier {
const registration = await this.serviceWorkerRegistration();
await registration.showNotification(
...toNotificationParams({
subscriptionId: subscription.id,
message: notification,
defaultTitle,
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()) {
return;
}
try {
const tag = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${tag}`);
const sequenceId = notification.sequence_id || notification.id;
const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
const registration = await this.serviceWorkerRegistration();
const notifications = await registration.getNotifications({ tag });
notifications.forEach((n) => n.close());

View File

@@ -19,7 +19,11 @@ class Pruner {
}
stopWorker() {
clearTimeout(this.timer);
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
console.log("[Pruner] Stopped worker");
}
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 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 sequenceId = message.sequence_id || message.id;
const tag = notificationTag(baseUrl, topic, sequenceId);
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
return [
@@ -62,7 +70,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
icon,
image,
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,
silent: false,
// This is used by the notification onclick event

View File

@@ -1,23 +1,23 @@
import {
Drawer,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Divider,
List,
Alert,
AlertTitle,
Badge,
Box,
Button,
CircularProgress,
Divider,
Drawer,
IconButton,
Link,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Portal,
Toolbar,
Tooltip,
Typography,
Box,
IconButton,
Button,
useTheme,
} from "@mui/material";
import * as React from "react";
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup";
import { useNotificationPermissionListener } from "./hooks";
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
const navWidth = 280;
@@ -91,6 +91,13 @@ const NavList = (props) => {
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const [versionChanged, setVersionChanged] = useState(false);
const handleVersionChange = () => {
setVersionChanged(true);
};
useVersionChangeListener(handleVersionChange);
const handleSubscribeReset = () => {
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 alertVisible =
versionChanged ||
showNotificationPermissionRequired ||
showNotificationPermissionDenied ||
showNotificationIOSInstallRequired ||
@@ -129,6 +137,7 @@ const NavList = (props) => {
<>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
{versionChanged && <VersionUpdateBanner />}
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
{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;

View File

@@ -9,6 +9,7 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
import accountApi from "../app/AccountApi";
import versionChecker from "../app/VersionChecker";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
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
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
@@ -58,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(subscription, notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(subscription, notification);
} else {
// Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id;
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) {
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) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
await handleNotification(subscription, message);
}
};
@@ -292,12 +293,14 @@ const startWorkers = () => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
versionChecker.startWorker();
};
const stopWorkers = () => {
poller.stopWorker();
pruner.stopWorker();
accountApi.stopWorker();
versionChecker.stopWorker();
};
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]);
};