diff --git a/client/options.go b/client/options.go
index f4711834..b99f1673 100644
--- a/client/options.go
+++ b/client/options.go
@@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption {
return WithHeader("X-Filename", filename)
}
+// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
+func WithSequenceID(sequenceID string) PublishOption {
+ return WithHeader("X-Sequence-ID", sequenceID)
+}
+
// WithEmail instructs the server to also send the message to the given e-mail address
func WithEmail(email string) PublishOption {
return WithHeader("X-Email", email)
diff --git a/cmd/publish.go b/cmd/publish.go
index f3139a63..c80c140b 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -34,6 +34,7 @@ var flagsPublish = append(
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
+ &cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
@@ -70,6 +71,7 @@ Examples:
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
+ ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates
echo 'message' | ntfy publish mytopic # Send message from stdin
ntfy pub -u phil:mypass secret Psst # Publish with username/password
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
@@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error {
markdown := c.Bool("markdown")
template := c.String("template")
filename := c.String("filename")
+ sequenceID := c.String("sequence-id")
file := c.String("file")
email := c.String("email")
user := c.String("user")
@@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error {
if filename != "" {
options = append(options, client.WithFilename(filename))
}
+ if sequenceID != "" {
+ options = append(options, client.WithSequenceID(sequenceID))
+ }
if email != "" {
options = append(options, client.WithEmail(email))
}
diff --git a/docs/faq.md b/docs/faq.md
index 5fa5252c..5153c700 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -96,8 +96,8 @@ appreciated.
## Can I email you? Can I DM you on Discord/Matrix?
For community support, please use the public channels listed on the [contact page](contact.md). I generally
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
-plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business
-opportunities (see [general inquiries](contact.md#general-inquiries)).
+plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
+opportunities (see [other inquiries](contact.md#other-inquiries)).
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
diff --git a/docs/publish.md b/docs/publish.md
index 9c409523..3363063a 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -937,6 +937,445 @@ Here's an example with a custom message, tags and a priority:
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
+## Updating + deleting notifications
+_Supported on:_ :material-android: :material-firefox:
+
+!!! info
+ **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later.
+
+You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
+like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
+
+* [Updating notifications](#updating-notifications) will alter the content of an existing notification.
+* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.
+* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).
+
+Here's an example of a download progress notification being updated over time on Android:
+
+
+

+

+
+
+To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence,
+using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the
+same sequence ID and the clients will perform the appropriate action on the existing notification.
+
+Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates
+the update, clear, or delete action. This append-only behavior ensures that message history remains intact.
+
+### Updating notifications
+To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous
+notification with the new one. You can either:
+
+1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates
+2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the
+ `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`)
+
+If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned
+message `id` to update it. Here's an example:
+
+=== "Command line (curl)"
+ ```bash
+ # First, publish a message and capture the message ID
+ curl -d "Downloading file..." ntfy.sh/mytopic
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it (via URL path)
+ curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi
+
+ # Or update using the X-Sequence-ID header
+ curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic
+ ```
+
+=== "ntfy CLI"
+ ```bash
+ # First, publish a message and capture the message ID
+ ntfy pub mytopic "Downloading file..."
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it
+ ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..."
+
+ # Update again with the same sequence ID
+ ntfy pub -S xE73Iyuabi mytopic "Download complete"
+ ```
+
+=== "HTTP"
+ ``` http
+ # First, publish a message and capture the message ID
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+
+ Downloading file...
+
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it
+ POST /mytopic/xE73Iyuabi HTTP/1.1
+ Host: ntfy.sh
+
+ Download 50% ...
+
+ # Update again with the same sequence ID, this time using the header
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+ X-Sequence-ID: xE73Iyuabi
+
+ Download complete
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ // First, publish and get the message ID
+ const response = await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ body: 'Downloading file...'
+ });
+ const { id } = await response.json();
+
+ // Update via URL path
+ await fetch(`https://ntfy.sh/mytopic/${id}`, {
+ method: 'POST',
+ body: 'Download 50% ...'
+ });
+
+ // Or update using the X-Sequence-ID header
+ await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ headers: { 'X-Sequence-ID': id },
+ body: 'Download complete'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ // Publish and parse the response to get the message ID
+ resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain",
+ strings.NewReader("Downloading file..."))
+ var msg struct { ID string `json:"id"` }
+ json.NewDecoder(resp.Body).Decode(&msg)
+
+ // Update via URL path
+ http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain",
+ strings.NewReader("Download 50% ..."))
+
+ // Or update using the X-Sequence-ID header
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
+ strings.NewReader("Download complete"))
+ req.Header.Set("X-Sequence-ID", msg.ID)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Publish and get the message ID
+ $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..."
+ $messageId = $response.id
+
+ # Update via URL path
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..."
+
+ # Or update using the X-Sequence-ID header
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
+ -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete"
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+
+ # Publish and get the message ID
+ response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...")
+ message_id = response.json()["id"]
+
+ # Update via URL path
+ requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...")
+
+ # Or update using the X-Sequence-ID header
+ requests.post("https://ntfy.sh/mytopic",
+ headers={"X-Sequence-ID": message_id}, data="Download complete")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ // Publish and get the message ID
+ $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Downloading file...']
+ ]));
+ $messageId = json_decode($response)->id;
+
+ // Update via URL path
+ file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
+ ]));
+
+ // Or update using the X-Sequence-ID header
+ file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "X-Sequence-ID: $messageId",
+ 'content' => 'Download complete'
+ ]
+ ]));
+ ```
+
+You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message.
+**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to
+`//`:
+
+=== "Command line (curl)"
+ ```bash
+ # Publish with a custom sequence ID
+ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123
+
+ # Update using the same sequence ID (via URL path)
+ curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123
+
+ # Or update using the X-Sequence-ID header
+ curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic
+ ```
+
+=== "ntfy CLI"
+ ```bash
+ # Publish with a sequence ID
+ ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..."
+
+ # Update using the same sequence ID
+ ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..."
+
+ # Update again
+ ntfy pub -S my-download-123 mytopic "Download complete"
+ ```
+
+=== "HTTP"
+ ``` http
+ # Publish a message with a custom sequence ID
+ POST /mytopic/my-download-123 HTTP/1.1
+ Host: ntfy.sh
+
+ Downloading file...
+
+ # Update again using the X-Sequence-ID header
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+ X-Sequence-ID: my-download-123
+
+ Download complete
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ // First message
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'POST',
+ body: 'Downloading file...'
+ });
+
+ // Update via URL path
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'POST',
+ body: 'Download 50% ...'
+ });
+
+ // Or update using the X-Sequence-ID header
+ await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ headers: { 'X-Sequence-ID': 'my-download-123' },
+ body: 'Download complete'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ // Publish with sequence ID in URL path
+ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
+ strings.NewReader("Downloading file..."))
+
+ // Update via URL path
+ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
+ strings.NewReader("Download 50% ..."))
+
+ // Or update using the X-Sequence-ID header
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
+ strings.NewReader("Download complete"))
+ req.Header.Set("X-Sequence-ID", "my-download-123")
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Publish with sequence ID
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..."
+
+ # Update via URL path
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..."
+
+ # Or update using the X-Sequence-ID header
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
+ -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete"
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+
+ # Publish with sequence ID
+ requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...")
+
+ # Update via URL path
+ requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...")
+
+ # Or update using the X-Sequence-ID header
+ requests.post("https://ntfy.sh/mytopic",
+ headers={"X-Sequence-ID": "my-download-123"}, data="Download complete")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ // Publish with sequence ID
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Downloading file...']
+ ]));
+
+ // Update via URL path
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
+ ]));
+
+ // Or update using the X-Sequence-ID header
+ file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => 'X-Sequence-ID: my-download-123',
+ 'content' => 'Download complete'
+ ]
+ ]));
+ ```
+
+You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when
+[publishing as JSON](#publish-as-json) using the `sequence_id` field.
+
+If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id`
+field the response. A sequence of updates may look like this (first example from above):
+
+```json
+{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."}
+{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."}
+{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"}
+```
+
+### Clearing notifications
+Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
+
+To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias).
+This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
+and dismiss the notification.
+
+=== "Command line (curl)"
+ ```bash
+ curl -X PUT ntfy.sh/mytopic/my-download-123/clear
+ ```
+
+=== "HTTP"
+ ``` http
+ PUT /mytopic/my-download-123/clear HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
+ method: 'PUT'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear"
+ ```
+
+=== "Python"
+ ``` python
+ requests.put("https://ntfy.sh/mytopic/my-download-123/clear")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([
+ 'http' => ['method' => 'PUT']
+ ]));
+ ```
+
+An example response from the server with the `message_clear` event may look like this:
+
+```json
+{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"}
+```
+
+### Deleting notifications
+Deleting a notification means **removing it from the notification drawer and from the client's database**.
+
+To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event
+that is used by the clients (web app and Android app) to remove the notification entirely.
+
+=== "Command line (curl)"
+ ```bash
+ curl -X DELETE ntfy.sh/mytopic/my-download-123
+ ```
+
+=== "HTTP"
+ ``` http
+ DELETE /mytopic/my-download-123 HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'DELETE'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123"
+ ```
+
+=== "Python"
+ ``` python
+ requests.delete("https://ntfy.sh/mytopic/my-download-123")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'DELETE']
+ ]));
+ ```
+
+An example response from the server with the `message_delete` event may look like this:
+
+```json
+{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"}
+```
+
+!!! info
+ Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will
+ reappear as a new message.
+
## Message templating
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -1418,22 +1857,23 @@ The JSON message format closely mirrors the format of the message you can consum
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields:
-| Field | Required | Type | Example | Description |
-|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------|
-| `topic` | ✔️ | *string* | `topic1` | Target topic name |
-| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
-| `title` | - | *string* | `Some title` | Message [title](#message-title) |
-| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
-| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
-| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
-| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
-| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
-| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
-| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
-| `filename` | - | *string* | `file.jpg` | File name of the attachment |
-| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
-| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
-| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
+| Field | Required | Type | Example | Description |
+|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
+| `topic` | ✔️ | *string* | `topic1` | Target topic name |
+| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
+| `title` | - | *string* | `Some title` | Message [title](#message-title) |
+| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
+| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
+| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
+| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
+| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
+| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
+| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
+| `filename` | - | *string* | `file.jpg` | File name of the attachment |
+| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
+| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
+| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
+| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -3931,6 +4371,7 @@ table in their canonical form.
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
+| `X-Sequence-ID` | `Sequence-ID`, `SID` | [Sequence ID](#updating-deleting-notifications) for updating/clearing/deleting notifications |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
diff --git a/docs/releases.md b/docs/releases.md
index ed65d8a4..2f3f669e 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1599,15 +1599,32 @@ 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)
+
### ntfy Android app v1.22.x (UNRELEASED)
**Features:**
-* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149)
+* 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 self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
+ [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
+ thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
+* Connection error dialog to help diagnose connection issues
**Bug fixes + maintenance:**
-* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting)
-* Fix crash in sharing dialog (thanks to @rogeliodh)
+* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
+ thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
+* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper
diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css
index 3c53aed6..4577ccce 100644
--- a/docs/static/css/extra.css
+++ b/docs/static/css/extra.css
@@ -1,10 +1,10 @@
:root > * {
- --md-primary-fg-color: #338574;
+ --md-primary-fg-color: #338574;
--md-primary-fg-color--light: #338574;
- --md-primary-fg-color--dark: #338574;
- --md-footer-bg-color: #353744;
- --md-text-font: "Roboto";
- --md-code-font: "Roboto Mono";
+ --md-primary-fg-color--dark: #338574;
+ --md-footer-bg-color: #353744;
+ --md-text-font: "Roboto";
+ --md-code-font: "Roboto Mono";
}
.md-header__button.md-logo :is(img, svg) {
@@ -34,7 +34,7 @@ figure img, figure video {
}
header {
- background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
+ background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
}
body[data-md-color-scheme="default"] header {
@@ -93,7 +93,7 @@ figure video {
.screenshots img {
max-height: 230px;
- max-width: 300px;
+ max-width: 350px;
margin: 3px;
border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd);
@@ -107,7 +107,7 @@ figure video {
opacity: 0;
visibility: hidden;
position: fixed;
- left:0;
+ left: 0;
right: 0;
top: 0;
bottom: 0;
@@ -119,7 +119,7 @@ figure video {
}
.lightbox.show {
- background-color: rgba(0,0,0, 0.75);
+ background-color: rgba(0, 0, 0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;
diff --git a/docs/static/img/android-screenshot-notification-update-1.png b/docs/static/img/android-screenshot-notification-update-1.png
new file mode 100644
index 00000000..16320de4
Binary files /dev/null and b/docs/static/img/android-screenshot-notification-update-1.png differ
diff --git a/docs/static/img/android-screenshot-notification-update-2.png b/docs/static/img/android-screenshot-notification-update-2.png
new file mode 100644
index 00000000..e94c4f21
Binary files /dev/null and b/docs/static/img/android-screenshot-notification-update-2.png differ
diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md
index a52e17f6..a549885b 100644
--- a/docs/subscribe/api.md
+++ b/docs/subscribe/api.md
@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
**Message**:
-| Field | Required | Type | Example | Description |
-|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
-| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
-| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
-| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
-| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
-| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
-| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
-| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` |
-| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
-| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
-| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
-| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
-| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
+| Field | Required | Type | Example | Description |
+|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
+| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
+| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
+| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
+| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
+| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
+| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
+| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
+| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` |
+| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
+| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
+| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
+| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
+| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
diff --git a/go.mod b/go.mod
index aa88fd68..9ef06c2c 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
- golang.org/x/crypto v0.46.0
+ golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0
@@ -92,12 +92,12 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
- golang.org/x/net v0.48.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
- google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
+ google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 72888b0f..241b5556 100644
--- a/go.sum
+++ b/go.sum
@@ -14,14 +14,10 @@ cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
-cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
-cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
-cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
-cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8=
cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
@@ -100,8 +96,6 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
@@ -118,8 +112,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
-github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -139,8 +131,6 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
-github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
@@ -194,8 +184,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -210,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -235,8 +225,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -248,8 +236,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
-golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -263,8 +249,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -279,27 +263,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
-google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
-google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
-google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
-google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
-google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY=
-google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI=
-google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
-google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
-google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
-google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
-google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
-google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
+google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4=
+google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY=
+google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
diff --git a/server/errors.go b/server/errors.go
index 098f785d..a29ff27d 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -3,8 +3,9 @@ package server
import (
"encoding/json"
"fmt"
- "heckel.io/ntfy/v2/log"
"net/http"
+
+ "heckel.io/ntfy/v2/log"
)
// errHTTP is a generic HTTP error for any non-200 HTTP error
@@ -125,6 +126,7 @@ var (
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
+ errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
diff --git a/server/message_cache.go b/server/message_cache.go
index 902cac1c..342f9687 100644
--- a/server/message_cache.go
+++ b/server/message_cache.go
@@ -29,7 +29,9 @@ const (
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
+ sequence_id TEXT NOT NULL,
time INT NOT NULL,
+ event TEXT NOT NULL,
expires INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
@@ -52,6 +54,7 @@ const (
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
+ CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
@@ -66,50 +69,50 @@ const (
COMMIT;
`
insertMessageQuery = `
- INSERT INTO messages (mid, time, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ 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 = `
- SELECT mid, time, 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
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
- SELECT mid, time, 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
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
- SELECT mid, time, 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
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
- SELECT mid, time, 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
- WHERE topic = ? AND id > ? AND published = 1
+ WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
- SELECT mid, time, 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
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
selectMessagesLatestQuery = `
- SELECT mid, time, 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
WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC
LIMIT 1
`
selectMessagesDueQuery = `
- SELECT mid, time, 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
WHERE time <= ? AND published = 0
ORDER BY time, id
@@ -131,7 +134,7 @@ const (
// Schema management queries
const (
- currentSchemaVersion = 13
+ currentSchemaVersion = 14
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -260,6 +263,13 @@ const (
migrate12To13AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
`
+
+ //13 -> 14
+ migrate13To14AlterMessagesTableQuery = `
+ ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
+ ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
+ CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
+ `
)
var (
@@ -277,6 +287,7 @@ var (
10: migrateFrom10,
11: migrateFrom11,
12: migrateFrom12,
+ 13: migrateFrom13,
}
)
@@ -369,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error {
}
defer stmt.Close()
for _, m := range ms {
- if m.Event != messageEvent {
+ if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
return errUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
@@ -397,7 +408,9 @@ func (c *messageCache) addMessages(ms []*message) error {
}
_, err := stmt.Exec(
m.ID,
+ m.SequenceID,
m.Time,
+ m.Event,
m.Expires,
m.Topic,
m.Message,
@@ -706,10 +719,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int
- var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
+ var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
err := rows.Scan(
&id,
+ &sequenceID,
×tamp,
+ &event,
&expires,
&topic,
&msg,
@@ -758,9 +773,10 @@ func readMessage(rows *sql.Rows) (*message, error) {
}
return &message{
ID: id,
+ SequenceID: sequenceID,
Time: timestamp,
Expires: expires,
- Event: messageEvent,
+ Event: event,
Topic: topic,
Message: msg,
Title: title,
@@ -1030,3 +1046,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
}
return tx.Commit()
}
+
+func migrateFrom13(db *sql.DB, _ time.Duration) error {
+ log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(updateSchemaVersion, 14); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/server/message_cache_test.go b/server/message_cache_test.go
index f0a02b2e..1e285605 100644
--- a/server/message_cache_test.go
+++ b/server/message_cache_test.go
@@ -319,6 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
+ m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "flower.jpg",
@@ -332,6 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2"
+ m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "car.jpg",
@@ -345,6 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3"
+ m.SequenceID = "m3"
m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{
@@ -400,11 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
+ m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with attachment")
m.ID = "m2"
+ m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -417,6 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3"
+ m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -428,6 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4"
+ m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "expired-car.jpg",
diff --git a/server/server.go b/server/server.go
index fc04d50f..3bd53ea6 100644
--- a/server/server.go
+++ b/server/server.go
@@ -80,11 +80,12 @@ var (
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
+ updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
+ clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
+ sequenceIDRegex = topicRegex
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
- webRootHTMLPath = "/app.html"
- webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
@@ -108,7 +109,7 @@ var (
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
- staticRegex = regexp.MustCompile(`^/static/.+`)
+ staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
urlRegex = regexp.MustCompile(`^https?://`)
@@ -137,7 +138,7 @@ var (
const (
firebaseControlTopic = "~control" // See Android if changed
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
- emptyMessageBody = "triggered" // Used if message body is empty
+ emptyMessageBody = "triggered" // Used when a message body is empty
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
@@ -531,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
- } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
+ } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@@ -543,8 +544,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
- } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
+ } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
+ } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
+ return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
+ } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
+ return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
@@ -872,7 +877,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err
}
minc(metricMessagesPublishedSuccess)
- return s.writeJSON(w, m)
+ return s.writeJSON(w, m.forJSON())
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -900,6 +905,58 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
return writeMatrixSuccess(w)
}
+func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ return s.handleActionMessage(w, r, v, messageDeleteEvent)
+}
+
+func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ return s.handleActionMessage(w, r, v, messageClearEvent)
+}
+
+func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
+ t, err := fromContext[*topic](r, contextTopic)
+ if err != nil {
+ return err
+ }
+ vrate, err := fromContext[*visitor](r, contextRateVisitor)
+ if err != nil {
+ return err
+ }
+ if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
+ return errHTTPTooManyRequestsLimitMessages.With(t)
+ }
+ sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
+ if e != nil {
+ return e.With(t)
+ }
+ // Create an action message with the given event type
+ m := newActionMessage(event, t.ID, sequenceID)
+ m.Sender = v.IP()
+ m.User = v.MaybeUserID()
+ m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
+ // Publish to subscribers
+ if err := t.Publish(v, m); err != nil {
+ return err
+ }
+ // Send to Firebase for Android clients
+ if s.firebaseClient != nil {
+ go s.sendToFirebase(v, m)
+ }
+ // Send to web push endpoints
+ if s.config.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
+ // Add to message cache
+ if err := s.messageCache.AddMessage(m); err != nil {
+ return err
+ }
+ logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
+ s.mu.Lock()
+ s.messages++
+ s.mu.Unlock()
+ return s.writeJSON(w, m.forJSON())
+}
+
func (s *Server) sendToFirebase(v *visitor, m *message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
@@ -957,6 +1014,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
+ if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
+ pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
+ if err != nil {
+ return false, false, "", "", "", false, err
+ }
+ m.SequenceID = pathSequenceID
+ } else {
+ sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
+ if sequenceID != "" {
+ if sequenceIDRegex.MatchString(sequenceID) {
+ m.SequenceID = sequenceID
+ } else {
+ return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
+ }
+ } else {
+ m.SequenceID = m.ID
+ }
+ }
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
@@ -1271,7 +1346,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
+ if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
return buf.String(), nil
@@ -1282,10 +1357,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
+ if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
- if msg.Event != messageEvent {
+ if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
}
return fmt.Sprintf("data: %s\n", buf.String()), nil
@@ -1695,6 +1770,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
return topics, parts[1], nil
}
+// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
+func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
+ parts := strings.Split(path, "/")
+ if len(parts) < 3 {
+ return "", errHTTPBadRequestSequenceIDInvalid
+ }
+ return parts[2], nil
+}
+
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
@@ -1949,6 +2033,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Firebase != "" {
r.Header.Set("X-Firebase", m.Firebase)
}
+ if m.SequenceID != "" {
+ r.Header.Set("X-Sequence-ID", m.SequenceID)
+ }
return next(w, r, v)
}
}
diff --git a/server/server_firebase.go b/server/server_firebase.go
index 13e80b93..9fde63a3 100644
--- a/server/server_firebase.go
+++ b/server/server_firebase.go
@@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"poll_id": m.PollID,
}
apnsConfig = createAPNSAlertConfig(m, data)
+ case messageDeleteEvent, messageClearEvent:
+ data = map[string]string{
+ "id": m.ID,
+ "time": fmt.Sprintf("%d", m.Time),
+ "event": m.Event,
+ "topic": m.Topic,
+ "sequence_id": m.SequenceID,
+ }
+ apnsConfig = createAPNSBackgroundConfig(data)
case messageEvent:
if auther != nil {
// If "anonymous read" for a topic is not allowed, we cannot send the message along
@@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
+ "sequence_id": m.SequenceID,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go
index 89004cd3..c98f528f 100644
--- a/server/server_firebase_test.go
+++ b/server/server_firebase_test.go
@@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
+ "sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
+ "sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
+ "sequence_id": "",
"message": "New message",
"title": "",
"tags": "",
diff --git a/server/server_test.go b/server/server_test.go
index 19c0165c..530d9458 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -8,8 +8,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
- "golang.org/x/crypto/bcrypt"
- "heckel.io/ntfy/v2/user"
"io"
"net/http"
"net/http/httptest"
@@ -24,7 +22,9 @@ import (
"time"
"github.com/stretchr/testify/require"
+ "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -678,6 +678,86 @@ func TestServer_PublishInvalidTopic(t *testing.T) {
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
}
+func TestServer_PublishWithSIDInPath(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic/sid", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDInHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic", "message", map[string]string{
+ "sid": "sid",
+ })
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{
+ "sid": "sid2",
+ })
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header
+}
+
+func TestServer_PublishWithSIDInQuery(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDViaGet(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID)
+}
+
+func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}`
+ response := request(t, s, "PUT", "/", body, nil)
+ require.Equal(t, 200, response.Code)
+
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "my-sequence-123", msg.SequenceID)
+}
+
+func TestServer_PublishWithInvalidSIDInPath(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic/.", "message", nil)
+
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic", "message", map[string]string{
+ "X-Sequence-ID": "*&?",
+ })
+
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code)
+}
+
func TestServer_PollWithQueryFilters(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -3209,6 +3289,212 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
}
+func TestServer_DeleteMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message with a sequence ID
+ response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq123", msg.SequenceID)
+ require.Equal(t, "message", msg.Event)
+
+ // Delete the message using DELETE method
+ response = request(t, s, "DELETE", "/mytopic/seq123", "", nil)
+ require.Equal(t, 200, response.Code)
+ deleteMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq123", deleteMsg.SequenceID)
+ require.Equal(t, "message_delete", deleteMsg.Event)
+
+ // Poll and verify both messages are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ msg1 := toMessage(t, lines[0])
+ msg2 := toMessage(t, lines[1])
+ require.Equal(t, "message", msg1.Event)
+ require.Equal(t, "message_delete", msg2.Event)
+ require.Equal(t, "seq123", msg1.SequenceID)
+ require.Equal(t, "seq123", msg2.SequenceID)
+}
+
+func TestServer_ClearMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message with a sequence ID
+ response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq456", msg.SequenceID)
+ require.Equal(t, "message", msg.Event)
+
+ // Clear the message using PUT /topic/seq/clear
+ response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil)
+ require.Equal(t, 200, response.Code)
+ clearMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq456", clearMsg.SequenceID)
+ require.Equal(t, "message_clear", clearMsg.Event)
+
+ // Poll and verify both messages are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ msg1 := toMessage(t, lines[0])
+ msg2 := toMessage(t, lines[1])
+ require.Equal(t, "message", msg1.Event)
+ require.Equal(t, "message_clear", msg2.Event)
+ require.Equal(t, "seq456", msg1.SequenceID)
+ require.Equal(t, "seq456", msg2.SequenceID)
+}
+
+func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
+ // Test that /topic/seq/read also works
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil)
+ require.Equal(t, 200, response.Code)
+
+ // Clear using /read endpoint
+ response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil)
+ require.Equal(t, 200, response.Code)
+ clearMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq789", clearMsg.SequenceID)
+ require.Equal(t, "message_clear", clearMsg.Event)
+}
+
+func TestServer_UpdateMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish original message
+ response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.Equal(t, "update-seq", msg1.SequenceID)
+ require.Equal(t, "original message", msg1.Message)
+
+ // Update the message (same sequence ID, new content)
+ response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, "update-seq", msg2.SequenceID)
+ require.Equal(t, "updated message", msg2.Message)
+ require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
+
+ // Poll and verify both versions are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ polledMsg1 := toMessage(t, lines[0])
+ polledMsg2 := toMessage(t, lines[1])
+ require.Equal(t, "original message", polledMsg1.Message)
+ require.Equal(t, "updated message", polledMsg2.Message)
+ require.Equal(t, "update-seq", polledMsg1.SequenceID)
+ require.Equal(t, "update-seq", polledMsg2.SequenceID)
+}
+
+func TestServer_UpdateMessage_UsingMessageID(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish original message without a sequence ID
+ response := request(t, s, "PUT", "/mytopic", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg1.ID)
+ require.Empty(t, msg1.SequenceID) // No sequence ID provided
+ require.Equal(t, "original message", msg1.Message)
+
+ // Update the message using the message ID as the sequence ID
+ response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID
+ require.Equal(t, "updated message", msg2.Message)
+ require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
+
+ // Poll and verify both versions are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ polledMsg1 := toMessage(t, lines[0])
+ polledMsg2 := toMessage(t, lines[1])
+ require.Equal(t, "original message", polledMsg1.Message)
+ require.Equal(t, "updated message", polledMsg2.Message)
+ require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID
+ require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID
+}
+
+func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Test invalid sequence ID for delete (returns 404 because route doesn't match)
+ response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil)
+ require.Equal(t, 404, response.Code)
+
+ // Test invalid sequence ID for clear (returns 404 because route doesn't match)
+ response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil)
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_DeleteMessage_WithFirebase(t *testing.T) {
+ sender := newTestFirebaseSender(10)
+ s := newTestServer(t, newTestConfig(t))
+ s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond) // Firebase publishing happens
+ require.Equal(t, 1, len(sender.Messages()))
+ require.Equal(t, "message", sender.Messages()[0].Data["event"])
+
+ // Delete the message
+ response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond) // Firebase publishing happens
+ require.Equal(t, 2, len(sender.Messages()))
+ require.Equal(t, "message_delete", sender.Messages()[1].Data["event"])
+ require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"])
+}
+
+func TestServer_ClearMessage_WithFirebase(t *testing.T) {
+ sender := newTestFirebaseSender(10)
+ s := newTestServer(t, newTestConfig(t))
+ s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond)
+ require.Equal(t, 1, len(sender.Messages()))
+
+ // Clear the message
+ response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond)
+ require.Equal(t, 2, len(sender.Messages()))
+ require.Equal(t, "message_clear", sender.Messages()[1].Data["event"])
+ require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
+}
+
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
diff --git a/server/server_webpush.go b/server/server_webpush.go
index 526e06f2..d3f09bd9 100644
--- a/server/server_webpush.go
+++ b/server/server_webpush.go
@@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
return
}
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
- payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
+ payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON()))
if err != nil {
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
return
diff --git a/server/types.go b/server/types.go
index d9519b94..6464222f 100644
--- a/server/types.go
+++ b/server/types.go
@@ -12,10 +12,12 @@ import (
// List of possible events
const (
- openEvent = "open"
- keepaliveEvent = "keepalive"
- messageEvent = "message"
- pollRequestEvent = "poll_request"
+ openEvent = "open"
+ keepaliveEvent = "keepalive"
+ messageEvent = "message"
+ messageDeleteEvent = "message_delete"
+ messageClearEvent = "message_clear"
+ pollRequestEvent = "poll_request"
)
const (
@@ -24,10 +26,11 @@ const (
// message represents a message published to a topic
type message struct {
- ID string `json:"id"` // Random message ID
- Time int64 `json:"time"` // Unix time in seconds
- Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
- Event string `json:"event"` // One of the above
+ ID string `json:"id"` // Random message ID
+ SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
+ Time int64 `json:"time"` // Unix time in seconds
+ Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
+ Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
@@ -39,18 +42,19 @@ type message struct {
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
- Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
+ Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
fields := map[string]any{
- "topic": m.Topic,
- "message_id": m.ID,
- "message_time": m.Time,
- "message_event": m.Event,
- "message_body_size": len(m.Message),
+ "topic": m.Topic,
+ "message_id": m.ID,
+ "message_sequence_id": m.SequenceID,
+ "message_time": m.Time,
+ "message_event": m.Event,
+ "message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String()
@@ -61,6 +65,17 @@ func (m *message) Context() log.Context {
return fields
}
+// forJSON returns a copy of the message suitable for JSON output.
+// It clears the SequenceID if it equals the ID to reduce redundancy.
+func (m *message) forJSON() *message {
+ if m.SequenceID == m.ID {
+ clone := *m
+ clone.SequenceID = ""
+ return &clone
+ }
+ return m
+}
+
type attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
@@ -91,22 +106,23 @@ func newAction() *action {
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
- Topic string `json:"topic"`
- Title string `json:"title"`
- Message string `json:"message"`
- Priority int `json:"priority"`
- Tags []string `json:"tags"`
- Click string `json:"click"`
- Icon string `json:"icon"`
- Actions []action `json:"actions"`
- Attach string `json:"attach"`
- Markdown bool `json:"markdown"`
- Filename string `json:"filename"`
- Email string `json:"email"`
- Call string `json:"call"`
- Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
- Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
- Delay string `json:"delay"`
+ Topic string `json:"topic"`
+ SequenceID string `json:"sequence_id"`
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Priority int `json:"priority"`
+ Tags []string `json:"tags"`
+ Click string `json:"click"`
+ Icon string `json:"icon"`
+ Actions []action `json:"actions"`
+ Attach string `json:"attach"`
+ Markdown bool `json:"markdown"`
+ Filename string `json:"filename"`
+ Email string `json:"email"`
+ Call string `json:"call"`
+ Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
+ Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
+ Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message
@@ -145,6 +161,13 @@ func newPollRequestMessage(topic, pollID string) *message {
return m
}
+// newActionMessage creates a new action message (message_delete or message_clear)
+func newActionMessage(event, topic, sequenceID string) *message {
+ m := newMessage(event, topic, "")
+ m.SequenceID = sequenceID
+ return m
+}
+
func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
@@ -223,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
}
func (q *queryFilter) Pass(msg *message) bool {
- if msg.Event != messageEvent {
+ if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return true // filters only apply to messages
} else if q.ID != "" && msg.ID != q.ID {
return false
diff --git a/web/package-lock.json b/web/package-lock.json
index a1bba16c..89894356 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -46,12 +46,12 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
- "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -60,9 +60,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
- "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -70,21 +70,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
- "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -108,13 +108,13 @@
"license": "MIT"
},
"node_modules/@babel/generator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
- "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -137,13 +137,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
- "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.27.2",
+ "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -154,18 +154,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz",
- "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+ "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
- "@babel/helper-replace-supers": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
- "@babel/traverse": "^7.28.5",
+ "@babel/traverse": "^7.28.6",
"semver": "^6.3.1"
},
"engines": {
@@ -234,28 +234,28 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
- "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -278,9 +278,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
- "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -306,15 +306,15 @@
}
},
"node_modules/@babel/helper-replace-supers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
- "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-member-expression-to-functions": "^7.27.1",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -366,41 +366,41 @@
}
},
"node_modules/@babel/helper-wrap-function": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz",
- "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.3",
- "@babel/types": "^7.28.2"
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
- "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
- "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.5"
+ "@babel/types": "^7.28.6"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -477,14 +477,14 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
- "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -507,13 +507,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-assertions": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
- "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -523,13 +523,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
- "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -572,15 +572,15 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz",
- "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz",
+ "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-remap-async-to-generator": "^7.27.1",
- "@babel/traverse": "^7.28.0"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -590,14 +590,14 @@
}
},
"node_modules/@babel/plugin-transform-async-to-generator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz",
- "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-remap-async-to-generator": "^7.27.1"
},
"engines": {
@@ -624,13 +624,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz",
- "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -640,14 +640,14 @@
}
},
"node_modules/@babel/plugin-transform-class-properties": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
- "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -657,14 +657,14 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
- "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.28.3",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -674,18 +674,18 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
- "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
- "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-replace-supers": "^7.27.1",
- "@babel/traverse": "^7.28.4"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -695,14 +695,14 @@
}
},
"node_modules/@babel/plugin-transform-computed-properties": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
- "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/template": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -729,14 +729,14 @@
}
},
"node_modules/@babel/plugin-transform-dotall-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
- "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -762,14 +762,14 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
- "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz",
+ "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -795,14 +795,14 @@
}
},
"node_modules/@babel/plugin-transform-explicit-resource-management": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
- "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/plugin-transform-destructuring": "^7.28.0"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -812,13 +812,13 @@
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
- "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -879,13 +879,13 @@
}
},
"node_modules/@babel/plugin-transform-json-strings": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
- "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -911,13 +911,13 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz",
- "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -960,14 +960,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz",
- "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1046,13 +1046,13 @@
}
},
"node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
- "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1062,13 +1062,13 @@
}
},
"node_modules/@babel/plugin-transform-numeric-separator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz",
- "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1078,17 +1078,17 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz",
- "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/plugin-transform-destructuring": "^7.28.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/traverse": "^7.28.4"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1115,13 +1115,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-catch-binding": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz",
- "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1131,13 +1131,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz",
- "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
@@ -1164,14 +1164,14 @@
}
},
"node_modules/@babel/plugin-transform-private-methods": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz",
- "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1181,15 +1181,15 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz",
- "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.27.1",
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1247,13 +1247,13 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz",
- "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz",
+ "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1263,14 +1263,14 @@
}
},
"node_modules/@babel/plugin-transform-regexp-modifiers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
- "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1312,13 +1312,13 @@
}
},
"node_modules/@babel/plugin-transform-spread": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz",
- "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
@@ -1393,14 +1393,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-property-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
- "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1427,14 +1427,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-sets-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
- "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1444,76 +1444,76 @@
}
},
"node_modules/@babel/preset-env": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz",
- "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz",
+ "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
"@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
- "@babel/plugin-syntax-import-assertions": "^7.27.1",
- "@babel/plugin-syntax-import-attributes": "^7.27.1",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
- "@babel/plugin-transform-async-generator-functions": "^7.28.0",
- "@babel/plugin-transform-async-to-generator": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.28.6",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
- "@babel/plugin-transform-block-scoping": "^7.28.5",
- "@babel/plugin-transform-class-properties": "^7.27.1",
- "@babel/plugin-transform-class-static-block": "^7.28.3",
- "@babel/plugin-transform-classes": "^7.28.4",
- "@babel/plugin-transform-computed-properties": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
"@babel/plugin-transform-destructuring": "^7.28.5",
- "@babel/plugin-transform-dotall-regex": "^7.27.1",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
- "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6",
"@babel/plugin-transform-dynamic-import": "^7.27.1",
- "@babel/plugin-transform-explicit-resource-management": "^7.28.0",
- "@babel/plugin-transform-exponentiation-operator": "^7.28.5",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-for-of": "^7.27.1",
"@babel/plugin-transform-function-name": "^7.27.1",
- "@babel/plugin-transform-json-strings": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
"@babel/plugin-transform-literals": "^7.27.1",
- "@babel/plugin-transform-logical-assignment-operators": "^7.28.5",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
"@babel/plugin-transform-modules-amd": "^7.27.1",
- "@babel/plugin-transform-modules-commonjs": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-modules-systemjs": "^7.28.5",
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
"@babel/plugin-transform-new-target": "^7.27.1",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
- "@babel/plugin-transform-numeric-separator": "^7.27.1",
- "@babel/plugin-transform-object-rest-spread": "^7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
"@babel/plugin-transform-object-super": "^7.27.1",
- "@babel/plugin-transform-optional-catch-binding": "^7.27.1",
- "@babel/plugin-transform-optional-chaining": "^7.28.5",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/plugin-transform-private-methods": "^7.27.1",
- "@babel/plugin-transform-private-property-in-object": "^7.27.1",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
"@babel/plugin-transform-property-literals": "^7.27.1",
- "@babel/plugin-transform-regenerator": "^7.28.4",
- "@babel/plugin-transform-regexp-modifiers": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.28.6",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
"@babel/plugin-transform-reserved-words": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
- "@babel/plugin-transform-spread": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
"@babel/plugin-transform-sticky-regex": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-typeof-symbol": "^7.27.1",
"@babel/plugin-transform-unicode-escapes": "^7.27.1",
- "@babel/plugin-transform-unicode-property-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
- "@babel/plugin-transform-unicode-sets-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
"@babel/preset-modules": "0.1.6-no-external-plugins",
"babel-plugin-polyfill-corejs2": "^0.4.14",
"babel-plugin-polyfill-corejs3": "^0.13.0",
@@ -1544,40 +1544,40 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
- "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
- "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6",
"debug": "^4.3.1"
},
"engines": {
@@ -1585,9 +1585,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
- "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -3823,9 +3823,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001763",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
- "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
+ "version": "1.0.30001764",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
"dev": true,
"funding": [
{
diff --git a/web/public/sw.js b/web/public/sw.js
index 56d66f16..6b298414 100644
--- a/web/public/sw.js
+++ b/web/public/sw.js
@@ -3,11 +3,16 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
-
import { dbAsync } from "../src/app/db";
-
-import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
+import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
+import {
+ EVENT_MESSAGE,
+ EVENT_MESSAGE_CLEAR,
+ EVENT_MESSAGE_DELETE,
+ WEBPUSH_EVENT_MESSAGE,
+ WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
+} from "../src/app/events";
/**
* General docs for service workers and PWAs:
@@ -21,25 +26,6 @@ import initI18n from "../src/app/i18n";
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
-const addNotification = async ({ subscriptionId, message }) => {
- const db = await dbAsync();
-
- await db.notifications.add({
- ...message,
- subscriptionId,
- // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
- new: 1,
- });
-
- await db.subscriptions.update(subscriptionId, {
- last: message.id,
- });
-
- const badgeCount = await db.notifications.where({ new: 1 }).count();
- console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
- self.navigator.setAppBadge?.(badgeCount);
-};
-
/**
* Handle a received web push message and show notification.
*
@@ -48,10 +34,35 @@ const addNotification = async ({ subscriptionId, message }) => {
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
- broadcastChannel.postMessage(message); // To potentially play sound
+ console.log("[ServiceWorker] Message received", data);
+
+ // Delete existing notification with same sequence ID (if any)
+ const sequenceId = message.sequence_id || message.id;
+ if (sequenceId) {
+ await db.notifications.where({ subscriptionId, sequenceId }).delete();
+ }
+
+ // Add notification to database
+ await db.notifications.add({
+ ...messageWithSequenceId(message),
+ subscriptionId,
+ new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
+ });
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+
+ // Update badge in PWA
+ const badgeCount = await db.notifications.where({ new: 1 }).count();
+ self.navigator.setAppBadge?.(badgeCount);
+
+ // Broadcast the message to potentially play a sound
+ broadcastChannel.postMessage(message);
- await addNotification({ subscriptionId, message });
await self.registration.showNotification(
...toNotificationParams({
subscriptionId,
@@ -62,11 +73,70 @@ const handlePushMessage = async (data) => {
);
};
+/**
+ * Handle a message_delete event: delete the notification from the database.
+ */
+const handlePushMessageDelete = async (data) => {
+ const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
+ console.log("[ServiceWorker] Deleting notification sequence", data);
+
+ // 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());
+ }
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+};
+
+/**
+ * Handle a message_clear event: clear/dismiss the notification.
+ */
+const handlePushMessageClear = async (data) => {
+ const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
+ console.log("[ServiceWorker] Marking notification as read", data);
+
+ // 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());
+ }
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+
+ // Update badge count
+ const badgeCount = await db.notifications.where({ new: 1 }).count();
+ self.navigator.setAppBadge?.(badgeCount);
+};
+
/**
* Handle a received web push subscription expiring.
*/
const handlePushSubscriptionExpiring = async (data) => {
const t = await initI18n();
+ console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
body: t("web_push_subscription_expiring_body"),
@@ -82,6 +152,7 @@ const handlePushSubscriptionExpiring = async (data) => {
*/
const handlePushUnknown = async (data) => {
const t = await initI18n();
+ console.log("[ServiceWorker] Unknown event received", data);
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
body: t("web_push_unknown_notification_body"),
@@ -96,13 +167,26 @@ const handlePushUnknown = async (data) => {
* @param {object} data see server/types.go, type webPushPayload
*/
const handlePush = async (data) => {
- if (data.event === "message") {
- await handlePushMessage(data);
- } else if (data.event === "subscription_expiring") {
- await handlePushSubscriptionExpiring(data);
- } else {
- await handlePushUnknown(data);
+ // This logic is (partially) duplicated in
+ // - Android: SubscriberService::onNotificationReceived()
+ // - Android: FirebaseService::onMessageReceived()
+ // - Web app: hooks.js:handleNotification()
+ // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
+
+ if (data.event === WEBPUSH_EVENT_MESSAGE) {
+ const { message } = data;
+ if (message.event === EVENT_MESSAGE) {
+ return await handlePushMessage(data);
+ } else if (message.event === EVENT_MESSAGE_DELETE) {
+ return await handlePushMessageDelete(data);
+ } else if (message.event === EVENT_MESSAGE_CLEAR) {
+ return await handlePushMessageClear(data);
+ }
+ } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
+ return await handlePushSubscriptionExpiring(data);
}
+
+ return await handlePushUnknown(data);
};
/**
@@ -113,10 +197,8 @@ const handleClick = async (event) => {
const t = await initI18n();
const clients = await self.clients.matchAll({ type: "window" });
-
const rootUrl = new URL(self.location.origin);
const rootClient = clients.find((client) => client.url === rootUrl.toString());
- // perhaps open on another topic
const fallbackClient = clients[0];
if (!event.notification.data?.message) {
@@ -232,6 +314,7 @@ precacheAndRoute(
// Claim all open windows
clientsClaim();
+
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();
diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js
index 5358cdde..8e02d6f7 100644
--- a/web/src/app/Connection.js
+++ b/web/src/app/Connection.js
@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
+import { EVENT_OPEN, isNotificationEvent } from "./events";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
@@ -48,10 +49,11 @@ class Connection {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
try {
const data = JSON.parse(event.data);
- if (data.event === "open") {
+ if (data.event === EVENT_OPEN) {
return;
}
- const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
+ // Accept message, message_delete, and message_clear events
+ const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data;
if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
return;
diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js
index 77bbdb1e..f6e47a7c 100644
--- a/web/src/app/Notifier.js
+++ b/web/src/app/Notifier.js
@@ -31,6 +31,21 @@ class Notifier {
);
}
+ async cancel(notification) {
+ if (!this.supported()) {
+ return;
+ }
+ try {
+ const tag = notification.sequence_id || notification.id;
+ console.log(`[Notifier] Cancelling notification with ${tag}`);
+ const registration = await this.serviceWorkerRegistration();
+ const notifications = await registration.getNotifications({ tag });
+ notifications.forEach((n) => n.close());
+ } catch (e) {
+ console.log(`[Notifier] Error cancelling notification`, e);
+ }
+ }
+
async playSound() {
// Play sound
const sound = await prefs.sound();
diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js
index 2261dddc..b455a308 100644
--- a/web/src/app/Poller.js
+++ b/web/src/app/Poller.js
@@ -1,5 +1,7 @@
import api from "./Api";
+import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
+import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events";
const delayMillis = 2000; // 2 seconds
const intervalMillis = 300000; // 5 minutes
@@ -42,12 +44,35 @@ class Poller {
const since = subscription.last;
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
- if (!notifications || notifications.length === 0) {
- console.log(`[Poller] No new notifications found for ${subscription.id}`);
- return;
+
+ // Filter out notifications older than the prune threshold
+ const deleteAfterSeconds = await prefs.deleteAfter();
+ const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0;
+ const recentNotifications =
+ pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications;
+
+ // Find the latest notification for each sequence ID
+ const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications);
+
+ // Delete all existing notifications for which the latest notification is marked as deleted
+ const deletedSequenceIds = Object.entries(latestBySequenceId)
+ .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE)
+ .map(([sequenceId]) => sequenceId);
+ if (deletedSequenceIds.length > 0) {
+ console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
+ await Promise.all(
+ deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
+ );
+ }
+
+ // Add only the latest notification for each non-deleted sequence
+ const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE);
+ if (notificationsToAdd.length > 0) {
+ console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);
+ await subscriptionManager.addNotifications(subscription.id, notificationsToAdd);
+ } else {
+ console.log(`[Poller] No new notifications found for ${subscription.id}`);
}
- console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
- await subscriptionManager.addNotifications(subscription.id, notifications);
}
pollInBackground(subscription) {
@@ -59,6 +84,21 @@ class Poller {
}
})();
}
+
+ /**
+ * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.
+ * Returns an object mapping sequenceId -> latest notification.
+ */
+ latestNotificationsBySequenceId(notifications) {
+ const latestBySequenceId = {};
+ notifications.forEach((notification) => {
+ const sequenceId = notification.sequence_id || notification.id;
+ if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {
+ latestBySequenceId[sequenceId] = notification;
+ }
+ });
+ return latestBySequenceId;
+ }
}
const poller = new Poller();
diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js
index de99b642..f909778e 100644
--- a/web/src/app/SubscriptionManager.js
+++ b/web/src/app/SubscriptionManager.js
@@ -3,6 +3,8 @@ import notifier from "./Notifier";
import prefs from "./Prefs";
import db from "./db";
import { topicUrl } from "./utils";
+import { messageWithSequenceId } from "./notificationUtils";
+import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
constructor(dbImpl) {
@@ -48,16 +50,17 @@ class SubscriptionManager {
}
async notify(subscriptionId, notification) {
+ if (notification.event !== EVENT_MESSAGE) {
+ return;
+ }
const subscription = await this.get(subscriptionId);
if (subscription.mutedUntil > 0) {
return;
}
-
const priority = notification.priority ?? 3;
if (priority < (await prefs.minPriority())) {
return;
}
-
await notifier.notify(subscription, notification);
}
@@ -157,7 +160,7 @@ class SubscriptionManager {
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return this.db.notifications
- .orderBy("time") // Sort by time first
+ .orderBy("time") // Sort by time
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
@@ -173,17 +176,22 @@ class SubscriptionManager {
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await this.db.notifications.get(notification.id);
- if (exists) {
+ if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
return false;
}
try {
- // sw.js duplicates this logic, so if you change it here, change it there too
+ // Note: Service worker (sw.js) and addNotifications() duplicates this logic,
+ // so if you change it here, change it there too.
+
+ // Add notification to database
await this.db.notifications.add({
- ...notification,
+ ...messageWithSequenceId(notification),
subscriptionId,
- // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
- new: 1,
- }); // FIXME consider put() for double tab
+ new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
+ });
+
+ // FIXME consider put() for double tab
+ // Update subscription last message id (for ?since=... queries)
await this.db.subscriptions.update(subscriptionId, {
last: notification.id,
});
@@ -195,7 +203,10 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
- const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
+ const notificationsWithSubscriptionId = notifications.map((notification) => ({
+ ...messageWithSequenceId(notification),
+ subscriptionId,
+ }));
const lastNotificationId = notifications.at(-1).id;
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, {
@@ -220,6 +231,10 @@ class SubscriptionManager {
await this.db.notifications.delete(notificationId);
}
+ async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
+ await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
+ }
+
async deleteNotifications(subscriptionId) {
await this.db.notifications.where({ subscriptionId }).delete();
}
@@ -228,6 +243,10 @@ class SubscriptionManager {
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
+ async markNotificationReadBySequenceId(subscriptionId, sequenceId) {
+ await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
+ }
+
async markNotificationsRead(subscriptionId) {
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
}
diff --git a/web/src/app/db.js b/web/src/app/db.js
index b28fb716..e088a267 100644
--- a/web/src/app/db.js
+++ b/web/src/app/db.js
@@ -11,13 +11,20 @@ const createDatabase = (username) => {
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
const db = new Dexie(dbName);
- db.version(2).stores({
+ db.version(3).stores({
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
- notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
+ notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
users: "&baseUrl,username",
prefs: "&key",
});
+ // When another connection (e.g., service worker or another tab) wants to upgrade,
+ // close this connection gracefully to allow the upgrade to proceed
+ db.on("versionchange", () => {
+ console.log("[db] versionchange event: closing database");
+ db.close();
+ });
+
return db;
};
diff --git a/web/src/app/events.js b/web/src/app/events.js
new file mode 100644
index 00000000..d5c5ab88
--- /dev/null
+++ b/web/src/app/events.js
@@ -0,0 +1,15 @@
+// Event types for ntfy messages
+// These correspond to the server event types in server/types.go
+
+export const EVENT_OPEN = "open";
+export const EVENT_KEEPALIVE = "keepalive";
+export const EVENT_MESSAGE = "message";
+export const EVENT_MESSAGE_DELETE = "message_delete";
+export const EVENT_MESSAGE_CLEAR = "message_clear";
+export const EVENT_POLL_REQUEST = "poll_request";
+
+export const WEBPUSH_EVENT_MESSAGE = "message";
+export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
+
+// Check if an event is a notification event (message, delete, or read)
+export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js
index 0bd5136d..a9b8f8ff 100644
--- a/web/src/app/notificationUtils.js
+++ b/web/src/app/notificationUtils.js
@@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => {
export const formatMessage = (m) => {
if (m.title) {
- return m.message;
+ return m.message || "";
}
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
- return `${emojiList.join(" ")} ${m.message}`;
+ return `${emojiList.join(" ")} ${m.message || ""}`;
}
- return m.message;
+ return m.message || "";
};
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
@@ -50,7 +50,7 @@ export const isImage = (attachment) => {
export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg";
-export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => {
+export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
const image = isImage(message.attachment) ? message.attachment.url : undefined;
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
@@ -61,8 +61,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
badge,
icon,
image,
- timestamp: message.time * 1_000,
- tag: subscriptionId,
+ timestamp: message.time * 1000,
+ tag: message.sequence_id || message.id, // Update notification if there is a sequence ID
renotify: true,
silent: false,
// This is used by the notification onclick event
@@ -79,3 +79,10 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
},
];
};
+
+export const messageWithSequenceId = (message) => {
+ if (message.sequenceId) {
+ return message;
+ }
+ return { ...message, sequenceId: message.sequence_id || message.id };
+};
diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js
index 519d4c6a..9dadd551 100644
--- a/web/src/components/hooks.js
+++ b/web/src/components/hooks.js
@@ -12,6 +12,7 @@ import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
+import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -50,9 +51,28 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
};
const handleNotification = async (subscriptionId, notification) => {
- const added = await subscriptionManager.addNotification(subscriptionId, notification);
- if (added) {
- await subscriptionManager.notify(subscriptionId, notification);
+ // This logic is (partially) duplicated in
+ // - Android: SubscriberService::onNotificationReceived()
+ // - Android: FirebaseService::onMessageReceived()
+ // - Web app: hooks.js:handleNotification()
+ // - 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);
+ } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
+ await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
+ await notifier.cancel(notification);
+ } else {
+ // Regular message: delete existing and add new
+ const sequenceId = notification.sequence_id || notification.id;
+ if (sequenceId) {
+ await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId);
+ }
+ const added = await subscriptionManager.addNotification(subscriptionId, notification);
+ if (added) {
+ await subscriptionManager.notify(subscriptionId, notification);
+ }
}
};
@@ -231,7 +251,9 @@ export const useIsLaunchedPWA = () => {
useEffect(() => {
if (isIOSStandalone) {
- return () => {}; // No need to listen for events on iOS
+ return () => {
+ // No need to listen for events on iOS
+ };
}
const handler = (evt) => {
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
diff --git a/web/src/registerSW.js b/web/src/registerSW.js
index adef4746..842cf80e 100644
--- a/web/src/registerSW.js
+++ b/web/src/registerSW.js
@@ -5,10 +5,19 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
const intervalMS = 60 * 60 * 1000;
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
-const registerSW = () =>
+const registerSW = () => {
+ console.log("[ServiceWorker] Registering service worker");
+ if (!("serviceWorker" in navigator)) {
+ console.warn("[ServiceWorker] Service workers not supported");
+ return;
+ }
+
viteRegisterSW({
onRegisteredSW(swUrl, registration) {
+ console.log("[ServiceWorker] Registered:", { swUrl, registration });
+
if (!registration) {
+ console.warn("[ServiceWorker] No registration returned");
return;
}
@@ -23,9 +32,16 @@ const registerSW = () =>
},
});
- if (resp?.status === 200) await registration.update();
+ if (resp?.status === 200) {
+ console.log("[ServiceWorker] Updating service worker");
+ await registration.update();
+ }
}, intervalMS);
},
+ onRegisterError(error) {
+ console.error("[ServiceWorker] Registration error:", error);
+ },
});
+};
export default registerSW;