Merge pull request #1536 from binwiederhier/303-update-notifications

Update/delete/clear notifications
This commit is contained in:
Philipp C. Heckel
2026-01-16 10:10:50 -05:00
committed by GitHub
31 changed files with 1590 additions and 471 deletions

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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:
<div id="updating-notifications-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
</div>
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 /<topic>` 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 /<topic>/<sequence_id>` with your own identifier, or use `POST /<topic>` 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
`/<topic>/<sequence_id>`:
=== "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 `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/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 `/<topic>/<sequence_id>` 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) |

View File

@@ -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

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -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/<topic>` |
| `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/<topic>` |
| `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):

10
go.mod
View File

@@ -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

47
go.sum
View File

@@ -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=

View File

@@ -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}

View File

@@ -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,
&timestamp,
&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()
}

View File

@@ -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",

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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": "",

View File

@@ -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"

View File

@@ -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

View File

@@ -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

526
web/package-lock.json generated
View File

@@ -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": [
{

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 });
}

View File

@@ -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;
};

15
web/src/app/events.js Normal file
View File

@@ -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;

View File

@@ -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 };
};

View File

@@ -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"}`);

View File

@@ -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;