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