Compare commits

...

99 Commits

Author SHA1 Message Date
binwiederhier
c1ee163cab Remove cache thing 2026-01-16 10:07:09 -05:00
binwiederhier
fcf57a04e1 Move event up 2026-01-16 09:36:27 -05:00
binwiederhier
0ad06e808f Self-review 2026-01-15 22:14:56 -05:00
binwiederhier
2cc4bf7d28 Fix webpush stuff 2026-01-15 21:55:20 -05:00
binwiederhier
5ae3774f19 Refine, docs 2026-01-15 19:06:05 -05:00
binwiederhier
94eb121f38 Docs updates 2026-01-15 13:07:07 -05:00
binwiederhier
e81be48bf3 MOre docs update 2026-01-15 10:32:48 -05:00
binwiederhier
db4a4776d3 Reword 2026-01-15 10:07:05 -05:00
binwiederhier
3d54260f79 Docs 2026-01-15 09:30:37 -05:00
binwiederhier
ea3008c707 Move, add screenshots 2026-01-14 22:18:57 -05:00
binwiederhier
2e39a1329c AI docs, needs work 2026-01-14 21:48:21 -05:00
binwiederhier
ff2b8167b4 Make closing notifications work 2026-01-14 21:17:21 -05:00
binwiederhier
96638b516c Fix service worker handling for updating/deleting 2026-01-14 20:46:18 -05:00
binwiederhier
dd9b36cf0a Fix db crash 2026-01-13 21:29:44 -05:00
binwiederhier
44f20f6b4c Add tests, fix firebase 2026-01-13 20:50:31 -05:00
binwiederhier
a3c16d81f8 Rename to clear 2026-01-13 16:31:13 -05:00
binwiederhier
b5f24b12dc Merge branch 'main' of github.com:binwiederhier/ntfy into 303-update-notifications 2026-01-12 21:38:55 -05:00
binwiederhier
898a52ec63 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-01-11 09:27:01 -05:00
binwiederhier
7066ba92a6 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-11 09:26:58 -05:00
binwiederhier
8960459520 Changelog 2026-01-11 09:26:44 -05:00
Kristijan \"Fremen\" Velkovski
f581af6d27 Translated using Weblate (Macedonian)
Currently translated at 23.0% (94 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-11 07:01:51 +00:00
Shoshin Akamine
88016a2549 Translated using Weblate (Japanese)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2026-01-11 07:01:49 +00:00
大王叫我来巡山
a7603d1dfb Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-01-11 07:01:47 +00:00
Philipp C. Heckel
65e2af9efb Merge pull request #1542 from ecntu/fix-typos
fix typos in docs
2026-01-10 22:29:22 -05:00
Emilio Cantú
090cd720c1 fix typos 2026-01-10 22:03:41 -05:00
binwiederhier
7bc8edf7c3 Bump, release notes 2026-01-10 20:47:59 -05:00
Kristijan \"Fremen\" Velkovski
13768d4289 Translated using Weblate (Macedonian)
Currently translated at 16.4% (67 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-10 05:01:49 +01:00
binwiederhier
37d71051de Refine 2026-01-08 20:57:55 -05:00
binwiederhier
5ad3de2904 Switch to event type 2026-01-08 20:50:23 -05:00
binwiederhier
66ea25c18b Add JSON publishing support 2026-01-08 15:45:50 -05:00
binwiederhier
1ab7ca876c Rename to sequence_id 2026-01-08 14:27:18 -05:00
binwiederhier
fd8cd5ca91 Comment 2026-01-08 13:45:05 -05:00
binwiederhier
7cffbfcd6d Simplify handleNotifications 2026-01-08 13:43:57 -05:00
binwiederhier
dffee9ea7d Remove forJSON 2026-01-08 11:39:32 -05:00
binwiederhier
239959e2a4 Revert some changes; make poller respect deleteAfter pref 2026-01-08 11:19:53 -05:00
binwiederhier
75abf2e245 Delete old messages with SID when new messages arrive 2026-01-08 10:28:02 -05:00
binwiederhier
bfbe73aea3 Update polling 2026-01-07 09:46:08 -05:00
Kristijan \"Fremen\" Velkovski
b3721c1b71 Translated using Weblate (Macedonian)
Currently translated at 14.7% (60 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-07 14:01:52 +00:00
cyberboh
544f7ef1cb Translated using Weblate (Indonesian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2026-01-07 14:01:47 +00:00
binwiederhier
2dd152df3f Manual fixes for AI slop 2026-01-06 18:02:08 -05:00
binwiederhier
2856793eff Deleted 2026-01-06 14:22:55 -05:00
Kristijan \"Fremen\" Velkovski
16bec00b38 Translated using Weblate (Macedonian)
Currently translated at 13.5% (55 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-06 06:01:47 +00:00
binwiederhier
f51e99dc80 Remove modified 2026-01-05 21:55:07 -05:00
binwiederhier
aca9a77498 Remove mtime 2026-01-05 21:14:29 -05:00
binwiederhier
1c2550d749 Merge branch 'main' into 303-update-notifications 2026-01-05 15:34:42 -05:00
binwiederhier
e18d64933f Release notes 2026-01-04 10:57:11 -05:00
josé m.
20ccee5d7d Translated using Weblate (Galician)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/gl/
2026-01-04 16:01:47 +01:00
binwiederhier
3575abcde3 Restructure contact page 2026-01-02 08:10:34 -05:00
binwiederhier
2cd444ece0 Contact page 2026-01-02 08:05:39 -05:00
binwiederhier
eca1ed4d8d Contact and contributing page 2026-01-02 07:59:02 -05:00
binwiederhier
4ce9508fd3 Privacy policy commit history 2026-01-02 07:44:39 -05:00
binwiederhier
4f9f1292f1 Update privacy policy 2026-01-02 07:35:06 -05:00
binwiederhier
d64376c5ac Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-01 21:49:37 -05:00
binwiederhier
275487b3fd Release notes 2026-01-01 21:49:26 -05:00
ezn24
9301546e6d Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/
2026-01-01 20:01:47 +01:00
大王叫我来巡山
49b3c724cf Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2025-12-31 15:03:58 +01:00
Philipp C. Heckel
7091775234 Merge pull request #1527 from cbrake/main
add BRun to list of integrations
2025-12-30 17:24:41 -05:00
Cliff Brake
90667e7176 add BRun to list of integrations 2025-12-30 17:19:07 -05:00
Philipp C. Heckel
414bbb0171 Merge pull request #1509 from mikaeldui/patch-1
Add Ferron reverse proxy example to config.md
2025-12-30 10:12:07 -05:00
binwiederhier
6e6d35157d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2025-12-30 10:07:05 -05:00
binwiederhier
c0f57a448d Document ?display= parameter, update changelog 2025-12-30 10:06:47 -05:00
Eskuero
b348bce06e Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2025-12-30 13:00:28 +01:00
Priit Jõerüüt
06577c99f2 Translated using Weblate (Estonian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2025-12-30 13:00:27 +01:00
luneth
f48a9aef2c Translated using Weblate (French)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2025-12-30 13:00:26 +01:00
Kachelkaiser
293dda3e79 Translated using Weblate (German)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2025-12-30 13:00:24 +01:00
109247019824
f52b29931f Translated using Weblate (Bulgarian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2025-12-30 13:00:23 +01:00
Shoshin Akamine
3dea1b12cf Translated using Weblate (Japanese)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2025-12-30 13:00:22 +01:00
binwiederhier
d83be77727 Release notes 2025-12-29 21:07:31 -05:00
binwiederhier
6215113c91 Release notes 2025-12-29 16:58:22 -05:00
binwiederhier
954ac89b51 Release notes 2025-12-28 20:20:26 -05:00
binwiederhier
76fbe19fd1 Release notes 2025-12-28 12:48:55 -05:00
binwiederhier
b27c4cf95d Release notes 2025-12-26 13:20:20 -05:00
binwiederhier
d915552788 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2025-12-23 20:23:57 -05:00
binwiederhier
c66ad4d7e3 Merge branch 'main' of github.com:binwiederhier/ntfy 2025-12-23 20:23:55 -05:00
binwiederhier
152a6b96d1 Run go fix 2025-12-23 20:23:48 -05:00
Philipp C. Heckel
f642b09ebf Merge pull request #1501 from aerusso/mrs/nofirebase-fbsend
do not build fbsend with nofirebase
2025-12-23 20:21:19 -05:00
Philipp C. Heckel
ec414d29be Merge pull request #1499 from khazit/patch-1
Add Simple Observability to integrations list
2025-12-23 20:20:29 -05:00
Philipp C. Heckel
0fbd3e33f3 Merge pull request #1518 from faytecCD/patch-1
Fix filter api example
2025-12-23 20:15:14 -05:00
binwiederhier
17870cb471 Fix use of deprecated secrets file 2025-12-21 21:37:55 -05:00
binwiederhier
4e03c96108 Bump release notes 2025-12-21 21:02:02 -05:00
luneth
6cacdd47f2 Translated using Weblate (French)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2025-12-21 21:00:21 +01:00
binwiederhier
a380860cab Bump 2025-12-20 20:53:18 -05:00
binwiederhier
7f842eaeb1 Release notes 2025-12-20 20:50:11 -05:00
binwiederhier
3e019bfe88 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2025-12-20 20:11:21 -05:00
faytecCD
430135606b Fix filter api example 2025-12-18 16:03:24 +08:00
Philipp C. Heckel
62a79d9802 Merge pull request #1517 from lukestein/lukestein-docs-corrections
Fix formatting in Python example in publish.md
2025-12-17 15:24:08 -05:00
Luke Stein
d9d02dbbc1 Fix formatting in Python example in publish.md 2025-12-17 14:56:59 -05:00
Albert Cervera i Areny
e0a9e3aa56 Translated using Weblate (Catalan)
Currently translated at 3.4% (14 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/
2025-12-17 19:00:19 +01:00
Philipp C. Heckel
ce878a5d03 Merge pull request #1505 from eliecharra/patch-1
docs: fix typo on `listen-metrics-http`
2025-12-11 11:16:46 -05:00
Mikael Dúi Bolinder
43fe1b9ad8 Add Ferron reverse proxy example 2025-12-07 17:57:29 +02:00
Elie CHARRA
b59f451c6a docs: fix typo on listen-metrics-http
The correct option is `metrics-listen-http`
2025-12-03 19:41:42 +01:00
Antonio Enrico Russo
693d2d630f do not build fbsend with nofirebase
The nofirebase build tag should remove all build dependencies on
firebase.  The fbsend test tool depends on firebase (and is also only
useful in builds that use firebase).  Hence, disable it.

Signed-off-by: Antonio Enrico Russo <aerusso@aerusso.net>
2025-11-28 09:24:44 -07:00
Ali Benkassou
025c2963a0 Add Simple Observability to integrations list 2025-11-25 17:45:40 +01:00
liilliil
76bf4a3de7 Translated using Weblate (Slavonic (Old Church))
Currently translated at 1.4% (6 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cu/
2025-11-23 10:51:16 +00:00
liilliil
b7ae47d61c Added translation using Weblate (Slavonic (Old Church)) 2025-11-22 11:05:36 +01:00
liilliil
4fa265ed28 Translated using Weblate (Esperanto)
Currently translated at 0.7% (3 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/eo/
2025-11-22 11:05:36 +01:00
Hunter Kehoe
8293a24cf9 update notification text using sid in web app 2025-10-17 22:10:11 -06:00
Hunter Kehoe
83b5470bc5 publish messages with a custom sequence ID 2025-10-17 19:01:41 -06:00
Hunter Kehoe
2aae3577cb add sid, mtime, and deleted to message_cache 2025-10-17 17:39:55 -06:00
58 changed files with 2962 additions and 882 deletions

View File

@@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta
## Reporting a Vulnerability
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).
You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
(my username is `binwiederhier`).

View File

@@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption {
return WithHeader("X-Filename", filename)
}
// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
func WithSequenceID(sequenceID string) PublishOption {
return WithHeader("X-Sequence-ID", sequenceID)
}
// WithEmail instructs the server to also send the message to the given e-mail address
func WithEmail(email string) PublishOption {
return WithHeader("X-Email", email)

View File

@@ -34,6 +34,7 @@ var flagsPublish = append(
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
&cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
@@ -70,6 +71,7 @@ Examples:
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates
echo 'message' | ntfy publish mytopic # Send message from stdin
ntfy pub -u phil:mypass secret Psst # Publish with username/password
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
@@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error {
markdown := c.Bool("markdown")
template := c.String("template")
filename := c.String("filename")
sequenceID := c.String("sequence-id")
file := c.String("file")
email := c.String("email")
user := c.String("user")
@@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error {
if filename != "" {
options = append(options, client.WithFilename(filename))
}
if sequenceID != "" {
options = append(options, client.WithSequenceID(sequenceID))
}
if email != "" {
options = append(options, client.WithEmail(email))
}

View File

@@ -1,5 +1,4 @@
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
// +build darwin linux dragonfly freebsd netbsd openbsd
package cmd

View File

@@ -1013,7 +1013,7 @@ or the root domain:
=== "caddy"
```
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
# via Discord/Matrix or in a GitHub issue.
# via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
ntfy.sh, http://nfty.sh {
@@ -1029,6 +1029,36 @@ or the root domain:
redir @httpget https://{host}{uri}
}
```
=== "ferron"
``` kdl
// /etc/ferron.kdl
// Note that this config is most certainly incomplete. Please help out and let me know what's missing
// via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
// Note: Ferron automatically handles both HTTP and WebSockets with proxy
ntfy.sh {
auto_tls
auto_tls_letsencrypt_production
protocols "h1" "h2" "h3"
proxy "http://127.0.0.1:2586"
// Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
// it to work with curl without the annoying https:// prefix
no_redirect_to_https #true
condition "is_get_topic" {
is_equal "{method}" "GET"
is_regex "{path}" "^/([-_a-z0-9]{0,64}$|docs/|static/)"
}
if "is_get_topic" {
no_redirect_to_https #false
}
}
```
## Firebase (FCM)
!!! info
@@ -1531,7 +1561,7 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
doing, and/or secure access to the endpoint in your reverse proxy.

46
docs/contact.md Normal file
View File

@@ -0,0 +1,46 @@
# Contact
This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the
ntfy community. Please choose the appropriate channel based on your needs.
## Support
### Community support
For general questions, feature discussions, and community help, please use one of these public channels:
| Channel | Link | Description |
|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------|
| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) |
| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) |
| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms |
| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests |
!!! info "Why public channels?"
Answering questions in public channels benefits the entire community. Other users can learn from the
discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support.
### Paid support
If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support
via the following channels:
| Channel | Contact | Description |
|-----------------------|-----------------------------------------------------|------------------------------------------|
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues |
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
## Security issues
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
## Other inquiries
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please
use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh).

43
docs/contributing.md Normal file
View File

@@ -0,0 +1,43 @@
# Contributing
Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer,
translator, or just an enthusiastic user.
## Code contributions
If you'd like to contribute code to ntfy:
1. Check out the [development guide](develop.md) to set up your environment
2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own
3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss
before investing significant time
4. Submit a pull request on GitHub
All contributions are welcome, from small bug fixes to major features.
## Translations
Help make ntfy accessible to users around the world! We use Hosted Weblate for translations:
- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/)
You can start translating immediately without any coding knowledge.
## Documentation
Found a typo? Want to improve the docs? Documentation contributions are very welcome:
- Edit any page directly on GitHub using the edit button
- Submit a pull request with your improvements
## Bug reports and feature requests
- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues)
Please search existing issues before creating a new one to avoid duplicates.
## Code of Conduct
Please be respectful and constructive in all interactions. See the
[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details.

View File

@@ -2,7 +2,7 @@
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
hesitate to reach out via one of the channels listed on the [contact page](contact.md).
## ntfy server
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
@@ -441,6 +441,6 @@ To have instant notifications/better notification delivery when using firebase,
1. In XCode, find the NTFY app target. **Not** the NSE app target.
1. Find the Asset/ folder in the project navigator
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
After that, you should be all set!

View File

@@ -94,11 +94,11 @@ I would be humbled if you helped me carry the server and developer account costs
appreciated.
## Can I email you? Can I DM you on Discord/Matrix?
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
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)), 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 the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
may be able to help out. I hope you understand.

View File

@@ -42,6 +42,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
## Integration via HTTP/SMTP/etc.
@@ -180,6 +181,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
## Blog + forum posts

View File

@@ -1,12 +1,194 @@
# Privacy policy
I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will
never monetize or sell your information, and this service and software will always stay free and open.**
**Last updated:** January 2, 2026
Neither the server nor the app record any personal information, or share any of the messages and topics with
any outside service. All data is exclusively used to make the service function properly. The only external service
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
or messages, though typically this is turned off.
## Our commitment to privacy
We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will
never monetize or sell your information**. The ntfy service and software will always stay free and open source.
If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md).
## Information we collect
### Account information (optional)
If you create an account on ntfy.sh, we collect:
- **Username** - A unique identifier you choose
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
- **Email address** - Only if you subscribe to a paid plan (for billing purposes)
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
You can use ntfy without creating an account. Anonymous usage is fully supported.
### Messages and notifications
- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support
message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires.
- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted.
- **Topic names** - The topic names you publish to or subscribe to are processed by our servers.
### Technical information
- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes,
though this is typically turned off.
- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time,
and the IP address of the last access.
- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription
endpoint to deliver notifications.
### Billing information (paid plans only)
If you subscribe to a paid plan, payment processing is handled by Stripe. We store:
- Stripe customer ID
- Subscription status and billing period
We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe.
## Third-party services
To provide the ntfy.sh service, we use the following third-party services:
### Firebase Cloud Messaging (FCM)
We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you
receive a notification through the mobile apps (Google Play or App Store versions):
- Message metadata and content may be transmitted through Google's FCM infrastructure
- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data
**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of
the Android app and use a self-hosted server, or use the instant delivery feature with your own server.
### Twilio (phone calls)
If you use the phone call notification feature (`X-Call` header), we use Twilio to:
- Make voice calls to your verified phone number
- Send SMS or voice calls for phone number verification
Your phone number is shared with Twilio to deliver these services. Twilio's
[privacy policy](https://www.twilio.com/legal/privacy) applies.
### Amazon SES (email delivery)
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
### Stripe (payments)
If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly
by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy).
Note: We have explicitly disabled Stripe's telemetry features in our integration.
### Web push providers
If you enable browser notifications in the ntfy web app, push messages are delivered through your browser
vendor's push service:
- Google (Chrome)
- Mozilla (Firefox)
- Apple (Safari)
- Microsoft (Edge)
Your browser's push subscription endpoint is shared with these providers to deliver notifications.
## Mobile applications
### Android app
The Android app is available from two sources:
- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is
**explicitly disabled** in our app.
- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain
a direct connection to the server.
The Android app stores the following data locally on your device:
- Subscribed topics and their settings
- Cached notifications
- User credentials (if you add a server with authentication)
- Application logs (for debugging, stored locally only)
### iOS app
The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications.
The app stores the following data locally on your device:
- Subscribed topics
- Cached notifications
- User credentials (if configured)
## Web application
The ntfy web app is a static website that stores all data locally in your browser:
- **IndexedDB** - Stores your subscriptions and cached notifications
- **Local Storage** - Stores your preferences and session information
No cookies are used for tracking. The web app does not have a backend beyond the ntfy API.
## Data retention
| Data type | Retention period |
|------------------------|---------------------------------------------------|
| Messages | 12 hours (configurable by server operators) |
| Attachments | 3 hours (configurable by server operators) |
| User accounts | Until you delete your account |
| Access tokens | Until you revoke them or delete your account |
| Phone numbers | Until you remove them or delete your account |
| Web push subscriptions | 60 days of inactivity, then automatically removed |
| Server logs | Varies; debugging logs are typically temporary |
## Self-hosting
If you prefer complete control over your data, you can [self-host your own ntfy server](install.md).
When self-hosting:
- You control all data storage and retention
- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations
- No data is shared with ntfy.sh or any third party (unless you configure those integrations)
The server and all apps are fully open source:
- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)
- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android)
- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios)
## Data security
- All connections to ntfy.sh are encrypted using TLS/HTTPS
- Passwords are hashed using bcrypt before storage
- Access tokens are generated using cryptographically secure random values
- The server does not log message content by default
## Your rights
You have the right to:
- **Access** - View your account information and data
- **Delete** - Delete your account and associated data via the web app
- **Export** - Your messages are available via the API while cached
To delete your account, use the account settings in the web app or contact us.
## Changes to this policy
We may update this privacy policy from time to time. Changes will be posted on this page with an updated
"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md).
For significant changes, we may provide additional notice on Discord/Matrix or through the
[announcements](https://ntfy.sh/announcements) ntfy topic.
## Contact
For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
For all other contact options, see the [contact page](contact.md).

View File

@@ -705,8 +705,8 @@ As of today, **Markdown is only supported in the web app.** Here's an example of
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="Look ma, **bold text**, *italics*, ..."
headers={ "Markdown": "yes" }))
data="Look ma, **bold text**, *italics*, ...",
headers={ "Markdown": "yes" })
```
=== "PHP"
@@ -937,6 +937,445 @@ Here's an example with a custom message, tags and a priority:
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
## Updating + deleting notifications
_Supported on:_ :material-android: :material-firefox:
!!! info
**This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later.
You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
* [Updating notifications](#updating-notifications) will alter the content of an existing notification.
* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.
* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).
Here's an example of a download progress notification being updated over time on Android:
<div id="updating-notifications-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
</div>
To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence,
using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the
same sequence ID and the clients will perform the appropriate action on the existing notification.
Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates
the update, clear, or delete action. This append-only behavior ensures that message history remains intact.
### Updating notifications
To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous
notification with the new one. You can either:
1. **Use the message ID**: First publish like normal to `POST /<topic>` without a sequence ID, then use the returned message `id` as the sequence ID for updates
2. **Use a custom sequence ID**: Publish directly to `POST /<topic>/<sequence_id>` with your own identifier, or use `POST /<topic>` with the
`X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`)
If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned
message `id` to update it. Here's an example:
=== "Command line (curl)"
```bash
# First, publish a message and capture the message ID
curl -d "Downloading file..." ntfy.sh/mytopic
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it (via URL path)
curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi
# Or update using the X-Sequence-ID header
curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic
```
=== "ntfy CLI"
```bash
# First, publish a message and capture the message ID
ntfy pub mytopic "Downloading file..."
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it
ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..."
# Update again with the same sequence ID
ntfy pub -S xE73Iyuabi mytopic "Download complete"
```
=== "HTTP"
``` http
# First, publish a message and capture the message ID
POST /mytopic HTTP/1.1
Host: ntfy.sh
Downloading file...
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it
POST /mytopic/xE73Iyuabi HTTP/1.1
Host: ntfy.sh
Download 50% ...
# Update again with the same sequence ID, this time using the header
POST /mytopic HTTP/1.1
Host: ntfy.sh
X-Sequence-ID: xE73Iyuabi
Download complete
```
=== "JavaScript"
``` javascript
// First, publish and get the message ID
const response = await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
body: 'Downloading file...'
});
const { id } = await response.json();
// Update via URL path
await fetch(`https://ntfy.sh/mytopic/${id}`, {
method: 'POST',
body: 'Download 50% ...'
});
// Or update using the X-Sequence-ID header
await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
headers: { 'X-Sequence-ID': id },
body: 'Download complete'
});
```
=== "Go"
``` go
// Publish and parse the response to get the message ID
resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain",
strings.NewReader("Downloading file..."))
var msg struct { ID string `json:"id"` }
json.NewDecoder(resp.Body).Decode(&msg)
// Update via URL path
http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain",
strings.NewReader("Download 50% ..."))
// Or update using the X-Sequence-ID header
req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
strings.NewReader("Download complete"))
req.Header.Set("X-Sequence-ID", msg.ID)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
# Publish and get the message ID
$response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..."
$messageId = $response.id
# Update via URL path
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..."
# Or update using the X-Sequence-ID header
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
-Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete"
```
=== "Python"
``` python
import requests
# Publish and get the message ID
response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...")
message_id = response.json()["id"]
# Update via URL path
requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...")
# Or update using the X-Sequence-ID header
requests.post("https://ntfy.sh/mytopic",
headers={"X-Sequence-ID": message_id}, data="Download complete")
```
=== "PHP"
``` php-inline
// Publish and get the message ID
$response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Downloading file...']
]));
$messageId = json_decode($response)->id;
// Update via URL path
file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
]));
// Or update using the X-Sequence-ID header
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "X-Sequence-ID: $messageId",
'content' => 'Download complete'
]
]));
```
You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message.
**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to
`/<topic>/<sequence_id>`:
=== "Command line (curl)"
```bash
# Publish with a custom sequence ID
curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123
# Update using the same sequence ID (via URL path)
curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123
# Or update using the X-Sequence-ID header
curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic
```
=== "ntfy CLI"
```bash
# Publish with a sequence ID
ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..."
# Update using the same sequence ID
ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..."
# Update again
ntfy pub -S my-download-123 mytopic "Download complete"
```
=== "HTTP"
``` http
# Publish a message with a custom sequence ID
POST /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
Downloading file...
# Update again using the X-Sequence-ID header
POST /mytopic HTTP/1.1
Host: ntfy.sh
X-Sequence-ID: my-download-123
Download complete
```
=== "JavaScript"
``` javascript
// First message
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'POST',
body: 'Downloading file...'
});
// Update via URL path
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'POST',
body: 'Download 50% ...'
});
// Or update using the X-Sequence-ID header
await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
headers: { 'X-Sequence-ID': 'my-download-123' },
body: 'Download complete'
});
```
=== "Go"
``` go
// Publish with sequence ID in URL path
http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
strings.NewReader("Downloading file..."))
// Update via URL path
http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
strings.NewReader("Download 50% ..."))
// Or update using the X-Sequence-ID header
req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
strings.NewReader("Download complete"))
req.Header.Set("X-Sequence-ID", "my-download-123")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
# Publish with sequence ID
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..."
# Update via URL path
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..."
# Or update using the X-Sequence-ID header
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
-Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete"
```
=== "Python"
``` python
import requests
# Publish with sequence ID
requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...")
# Update via URL path
requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...")
# Or update using the X-Sequence-ID header
requests.post("https://ntfy.sh/mytopic",
headers={"X-Sequence-ID": "my-download-123"}, data="Download complete")
```
=== "PHP"
``` php-inline
// Publish with sequence ID
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Downloading file...']
]));
// Update via URL path
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
]));
// Or update using the X-Sequence-ID header
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'X-Sequence-ID: my-download-123',
'content' => 'Download complete'
]
]));
```
You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when
[publishing as JSON](#publish-as-json) using the `sequence_id` field.
If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id`
field the response. A sequence of updates may look like this (first example from above):
```json
{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."}
{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."}
{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"}
```
### Clearing notifications
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
and dismiss the notification.
=== "Command line (curl)"
```bash
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
```
=== "HTTP"
``` http
PUT /mytopic/my-download-123/clear HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
method: 'PUT'
});
```
=== "Go"
``` go
req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear"
```
=== "Python"
``` python
requests.put("https://ntfy.sh/mytopic/my-download-123/clear")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([
'http' => ['method' => 'PUT']
]));
```
An example response from the server with the `message_clear` event may look like this:
```json
{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"}
```
### Deleting notifications
Deleting a notification means **removing it from the notification drawer and from the client's database**.
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
that is used by the clients (web app and Android app) to remove the notification entirely.
=== "Command line (curl)"
```bash
curl -X DELETE ntfy.sh/mytopic/my-download-123
```
=== "HTTP"
``` http
DELETE /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'DELETE'
});
```
=== "Go"
``` go
req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123"
```
=== "Python"
``` python
requests.delete("https://ntfy.sh/mytopic/my-download-123")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'DELETE']
]));
```
An example response from the server with the `message_delete` event may look like this:
```json
{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"}
```
!!! info
Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will
reappear as a new message.
## Message templating
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -1418,22 +1857,23 @@ The JSON message format closely mirrors the format of the message you can consum
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields:
| Field | Required | Type | Example | Description |
|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| Field | Required | Type | Example | Description |
|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -3931,6 +4371,7 @@ table in their canonical form.
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Sequence-ID` | `Sequence-ID`, `SID` | [Sequence ID](#updating-deleting-notifications) for updating/clearing/deleting notifications |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |

View File

@@ -1174,7 +1174,7 @@ keys $myDict | sortAlpha
```
When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`
function along with `sortAlpha` to get a unqiue, sorted list of keys.
function along with `sortAlpha` to get a unique, sorted list of keys.
```
keys $myDict $myOtherDict | uniq | sortAlpha

View File

@@ -2,7 +2,88 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.15.0
## Current stable releases
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.15.0 | Nov 16, 2025 |
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
## ntfy Android app v1.21.1
Released January 6, 2026
This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
support for passing headers to your proxy, an in-app language switcher, and more.
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
**Features:**
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
**Bug fixes + maintenance:**
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
* Fix crash in user add dialog (onAddUser)
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
## ntfy Android app v1.20.0
Released December 28, 2025
This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version,
and fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries
themselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum
SDK version is 26 (Android 8, 2017).
**Bug fixes + maintenance:**
* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140),
thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation)
* Updated target SDK version to 36 (Android 8, 2017)
* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520))
* Fixed crashes with redrawing the list when temporarily muted topics expire
## ntfy Android app v1.19.4
Released December 21, 2025
This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the
ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors).
**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock!
**Features:**
* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580),
[ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126),
[ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro)
and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting)
## ntfy Android app v1.18.0
Released December 4, 2025
**Features:**
* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting)
* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla))
* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g))
**Bug fixes + maintenance:**
* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684))
* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g))
## ntfy server v2.15.0
Released Nov 16, 2025
This release adds a `require-login` flag to topics, which forces users to log in before they can
@@ -30,11 +111,11 @@ The policies do not allow directly or indirectly linking to paid plans or donati
**Changes:**
- Remove the "Donate" button from menu (all variants)
- Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
- Remove links to ntfy docs and issue tracker (Play variant only)
- Remove how-to links to ntfy.sh in a few places (Play variant only)
- Remove "Copy topic address" from subscription menu (Play variant only)
* Remove the "Donate" button from menu (all variants)
* Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
* Remove links to ntfy docs and issue tracker (Play variant only)
* Remove how-to links to ntfy.sh in a few places (Play variant only)
* Remove "Copy topic address" from subscription menu (Play variant only)
## ntfy Android app v1.17.8
Released September 23, 2025
@@ -1518,4 +1599,32 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
_Nothing to see, move along ..._
### 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 [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](https://github.com/binwiederhier/ntfy/issues/1529),
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper

View File

@@ -1,10 +1,10 @@
:root > * {
--md-primary-fg-color: #338574;
--md-primary-fg-color: #338574;
--md-primary-fg-color--light: #338574;
--md-primary-fg-color--dark: #338574;
--md-footer-bg-color: #353744;
--md-text-font: "Roboto";
--md-code-font: "Roboto Mono";
--md-primary-fg-color--dark: #338574;
--md-footer-bg-color: #353744;
--md-text-font: "Roboto";
--md-code-font: "Roboto Mono";
}
.md-header__button.md-logo :is(img, svg) {
@@ -34,7 +34,7 @@ figure img, figure video {
}
header {
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
}
body[data-md-color-scheme="default"] header {
@@ -93,7 +93,7 @@ figure video {
.screenshots img {
max-height: 230px;
max-width: 300px;
max-width: 350px;
margin: 3px;
border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd);
@@ -107,7 +107,7 @@ figure video {
opacity: 0;
visibility: hidden;
position: fixed;
left:0;
left: 0;
right: 0;
top: 0;
bottom: 0;
@@ -119,7 +119,7 @@ figure video {
}
.lightbox.show {
background-color: rgba(0,0,0, 0.75);
background-color: rgba(0, 0, 0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -295,7 +295,7 @@ Available filters (all case-insensitive):
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic/json?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
### Subscribe to multiple topics
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
**Message**:
| Field | Required | Type | Example | Description |
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `expires` | (✔) | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
| Field | Required | Type | Example | Description |
|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `expires` | (✔) | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):

View File

@@ -100,7 +100,7 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.
notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
## Share to topic
_Supported on:_ :material-android:
@@ -129,10 +129,11 @@ or to simply directly link to a topic from a mobile website.
**Supported link formats:**
| Link format | Example | Description |
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
| Link format | Example | Description |
|---------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
## Integrations

View File

@@ -1,7 +1,7 @@
# Troubleshooting
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
and ask there. We're happy to help.
If this page does not help, feel free to reach out via one of the channels listed on the [contact page](contact.md).
We're happy to help.
## ntfy server
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing

64
go.mod
View File

@@ -6,22 +6,22 @@ toolchain go1.24.5
require (
cloud.google.com/go/firestore v1.20.0 // indirect
cloud.google.com/go/storage v1.57.2 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
cloud.google.com/go/storage v1.59.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.11
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.32
github.com/mattn/go-sqlite3 v1.14.33
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.44.0
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0
golang.org/x/term v0.37.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
golang.org/x/time v0.14.0
google.golang.org/api v0.256.0
google.golang.org/api v0.259.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -35,17 +35,17 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/text v0.31.0
golang.org/x/text v0.33.0
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/longrunning v0.7.0 // indirect
cloud.google.com/go/longrunning v0.8.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
@@ -55,11 +55,11 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -69,36 +69,36 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
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.47.0 // indirect
golang.org/x/sys v0.38.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-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // 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
)

136
go.sum
View File

@@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
@@ -14,20 +14,20 @@ 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.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
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=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e h1:gt7U1Igw0xbJdyaCM5H2CnlAlPSkzrhsebQB6WQWjLA=
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -58,18 +58,18 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -96,10 +96,10 @@ 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/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
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=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -112,8 +112,8 @@ 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=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -131,8 +131,8 @@ 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.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
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=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -156,24 +156,24 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -184,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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
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=
@@ -200,10 +200,10 @@ 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -211,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -225,8 +225,8 @@ 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -236,8 +236,8 @@ 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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -249,8 +249,8 @@ 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -263,22 +263,22 @@ 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.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
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/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-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
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=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -99,6 +99,8 @@ nav:
- "Known issues": known-issues.md
- "Deprecation notices": deprecations.md
- "Development": develop.md
- "Contributing": contributing.md
- "Privacy policy": privacy.md
- "Contact": contact.md

View File

@@ -3,8 +3,9 @@ package server
import (
"encoding/json"
"fmt"
"heckel.io/ntfy/v2/log"
"net/http"
"heckel.io/ntfy/v2/log"
)
// errHTTP is a generic HTTP error for any non-200 HTTP error
@@ -125,6 +126,7 @@ var (
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

View File

@@ -29,7 +29,9 @@ const (
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time INT NOT NULL,
event TEXT NOT NULL,
expires INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
@@ -52,6 +54,7 @@ const (
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
@@ -66,50 +69,50 @@ const (
COMMIT;
`
insertMessageQuery = `
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND id > ? AND published = 1
WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
selectMessagesLatestQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC
LIMIT 1
`
selectMessagesDueQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
@@ -131,7 +134,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 13
currentSchemaVersion = 14
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -260,6 +263,13 @@ const (
migrate12To13AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
`
//13 -> 14
migrate13To14AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
`
)
var (
@@ -277,6 +287,7 @@ var (
10: migrateFrom10,
11: migrateFrom11,
12: migrateFrom12,
13: migrateFrom13,
}
)
@@ -369,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error {
}
defer stmt.Close()
for _, m := range ms {
if m.Event != messageEvent {
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
return errUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
@@ -397,7 +408,9 @@ func (c *messageCache) addMessages(ms []*message) error {
}
_, err := stmt.Exec(
m.ID,
m.SequenceID,
m.Time,
m.Event,
m.Expires,
m.Topic,
m.Message,
@@ -706,10 +719,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
err := rows.Scan(
&id,
&sequenceID,
&timestamp,
&event,
&expires,
&topic,
&msg,
@@ -758,9 +773,10 @@ func readMessage(rows *sql.Rows) (*message, error) {
}
return &message{
ID: id,
SequenceID: sequenceID,
Time: timestamp,
Expires: expires,
Event: messageEvent,
Event: event,
Topic: topic,
Message: msg,
Title: title,
@@ -1030,3 +1046,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
}
return tx.Commit()
}
func migrateFrom13(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 14); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -319,6 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "flower.jpg",
@@ -332,6 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2"
m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "car.jpg",
@@ -345,6 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3"
m.SequenceID = "m3"
m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{
@@ -400,11 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with attachment")
m.ID = "m2"
m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -417,6 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3"
m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -428,6 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4"
m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "expired-car.jpg",

View File

@@ -80,11 +80,12 @@ var (
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
sequenceIDRegex = topicRegex
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
webRootHTMLPath = "/app.html"
webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
@@ -108,7 +109,7 @@ var (
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
urlRegex = regexp.MustCompile(`^https?://`)
@@ -137,7 +138,7 @@ var (
const (
firebaseControlTopic = "~control" // See Android if changed
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
emptyMessageBody = "triggered" // Used if message body is empty
emptyMessageBody = "triggered" // Used when a message body is empty
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
@@ -531,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@@ -543,8 +544,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
@@ -872,7 +877,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err
}
minc(metricMessagesPublishedSuccess)
return s.writeJSON(w, m)
return s.writeJSON(w, m.forJSON())
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -900,6 +905,58 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
return writeMatrixSuccess(w)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageDeleteEvent)
}
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageClearEvent)
}
func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
t, err := fromContext[*topic](r, contextTopic)
if err != nil {
return err
}
vrate, err := fromContext[*visitor](r, contextRateVisitor)
if err != nil {
return err
}
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return errHTTPTooManyRequestsLimitMessages.With(t)
}
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
if e != nil {
return e.With(t)
}
// Create an action message with the given event type
m := newActionMessage(event, t.ID, sequenceID)
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
// Publish to subscribers
if err := t.Publish(v, m); err != nil {
return err
}
// Send to Firebase for Android clients
if s.firebaseClient != nil {
go s.sendToFirebase(v, m)
}
// Send to web push endpoints
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
// Add to message cache
if err := s.messageCache.AddMessage(m); err != nil {
return err
}
logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
s.mu.Lock()
s.messages++
s.mu.Unlock()
return s.writeJSON(w, m.forJSON())
}
func (s *Server) sendToFirebase(v *visitor, m *message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
@@ -957,6 +1014,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
if err != nil {
return false, false, "", "", "", false, err
}
m.SequenceID = pathSequenceID
} else {
sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
if sequenceID != "" {
if sequenceIDRegex.MatchString(sequenceID) {
m.SequenceID = sequenceID
} else {
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
}
} else {
m.SequenceID = m.ID
}
}
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
@@ -1271,7 +1346,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
return buf.String(), nil
@@ -1282,10 +1357,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
if msg.Event != messageEvent {
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
}
return fmt.Sprintf("data: %s\n", buf.String()), nil
@@ -1695,6 +1770,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
return topics, parts[1], nil
}
// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
parts := strings.Split(path, "/")
if len(parts) < 3 {
return "", errHTTPBadRequestSequenceIDInvalid
}
return parts[2], nil
}
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
@@ -1949,6 +2033,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Firebase != "" {
r.Header.Set("X-Firebase", m.Firebase)
}
if m.SequenceID != "" {
r.Header.Set("X-Sequence-ID", m.SequenceID)
}
return next(w, r, v)
}
}

View File

@@ -80,7 +80,7 @@ type firebaseSenderImpl struct {
}
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile))
if err != nil {
return nil, err
}
@@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"poll_id": m.PollID,
}
apnsConfig = createAPNSAlertConfig(m, data)
case messageDeleteEvent, messageClearEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"sequence_id": m.SequenceID,
}
apnsConfig = createAPNSBackgroundConfig(data)
case messageEvent:
if auther != nil {
// If "anonymous read" for a topic is not allowed, we cannot send the message along
@@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"sequence_id": m.SequenceID,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,

View File

@@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
"sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
"sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
"sequence_id": "",
"message": "New message",
"title": "",
"tags": "",

View File

@@ -8,8 +8,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/user"
"io"
"net/http"
"net/http/httptest"
@@ -24,7 +22,9 @@ import (
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -678,6 +678,86 @@ func TestServer_PublishInvalidTopic(t *testing.T) {
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishWithSIDInPath(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic/sid", "message", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "sid", msg.SequenceID)
}
func TestServer_PublishWithSIDInHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "message", map[string]string{
"sid": "sid",
})
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "sid", msg.SequenceID)
}
func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{
"sid": "sid2",
})
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header
}
func TestServer_PublishWithSIDInQuery(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "sid1", msg.SequenceID)
}
func TestServer_PublishWithSIDViaGet(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "sid1", msg.SequenceID)
}
func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "my-sequence-123", msg.SequenceID)
}
func TestServer_PublishWithInvalidSIDInPath(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic/.", "message", nil)
require.Equal(t, 404, response.Code)
}
func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "message", map[string]string{
"X-Sequence-ID": "*&?",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PollWithQueryFilters(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -3209,6 +3289,212 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
}
func TestServer_DeleteMessage(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a message with a sequence ID
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "seq123", msg.SequenceID)
require.Equal(t, "message", msg.Event)
// Delete the message using DELETE method
response = request(t, s, "DELETE", "/mytopic/seq123", "", nil)
require.Equal(t, 200, response.Code)
deleteMsg := toMessage(t, response.Body.String())
require.Equal(t, "seq123", deleteMsg.SequenceID)
require.Equal(t, "message_delete", deleteMsg.Event)
// Poll and verify both messages are returned
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 2, len(lines))
msg1 := toMessage(t, lines[0])
msg2 := toMessage(t, lines[1])
require.Equal(t, "message", msg1.Event)
require.Equal(t, "message_delete", msg2.Event)
require.Equal(t, "seq123", msg1.SequenceID)
require.Equal(t, "seq123", msg2.SequenceID)
}
func TestServer_ClearMessage(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a message with a sequence ID
response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "seq456", msg.SequenceID)
require.Equal(t, "message", msg.Event)
// Clear the message using PUT /topic/seq/clear
response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil)
require.Equal(t, 200, response.Code)
clearMsg := toMessage(t, response.Body.String())
require.Equal(t, "seq456", clearMsg.SequenceID)
require.Equal(t, "message_clear", clearMsg.Event)
// Poll and verify both messages are returned
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 2, len(lines))
msg1 := toMessage(t, lines[0])
msg2 := toMessage(t, lines[1])
require.Equal(t, "message", msg1.Event)
require.Equal(t, "message_clear", msg2.Event)
require.Equal(t, "seq456", msg1.SequenceID)
require.Equal(t, "seq456", msg2.SequenceID)
}
func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
// Test that /topic/seq/read also works
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish a message
response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil)
require.Equal(t, 200, response.Code)
// Clear using /read endpoint
response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil)
require.Equal(t, 200, response.Code)
clearMsg := toMessage(t, response.Body.String())
require.Equal(t, "seq789", clearMsg.SequenceID)
require.Equal(t, "message_clear", clearMsg.Event)
}
func TestServer_UpdateMessage(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish original message
response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil)
require.Equal(t, 200, response.Code)
msg1 := toMessage(t, response.Body.String())
require.Equal(t, "update-seq", msg1.SequenceID)
require.Equal(t, "original message", msg1.Message)
// Update the message (same sequence ID, new content)
response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil)
require.Equal(t, 200, response.Code)
msg2 := toMessage(t, response.Body.String())
require.Equal(t, "update-seq", msg2.SequenceID)
require.Equal(t, "updated message", msg2.Message)
require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
// Poll and verify both versions are returned
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 2, len(lines))
polledMsg1 := toMessage(t, lines[0])
polledMsg2 := toMessage(t, lines[1])
require.Equal(t, "original message", polledMsg1.Message)
require.Equal(t, "updated message", polledMsg2.Message)
require.Equal(t, "update-seq", polledMsg1.SequenceID)
require.Equal(t, "update-seq", polledMsg2.SequenceID)
}
func TestServer_UpdateMessage_UsingMessageID(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Publish original message without a sequence ID
response := request(t, s, "PUT", "/mytopic", "original message", nil)
require.Equal(t, 200, response.Code)
msg1 := toMessage(t, response.Body.String())
require.NotEmpty(t, msg1.ID)
require.Empty(t, msg1.SequenceID) // No sequence ID provided
require.Equal(t, "original message", msg1.Message)
// Update the message using the message ID as the sequence ID
response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil)
require.Equal(t, 200, response.Code)
msg2 := toMessage(t, response.Body.String())
require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID
require.Equal(t, "updated message", msg2.Message)
require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
// Poll and verify both versions are returned
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 2, len(lines))
polledMsg1 := toMessage(t, lines[0])
polledMsg2 := toMessage(t, lines[1])
require.Equal(t, "original message", polledMsg1.Message)
require.Equal(t, "updated message", polledMsg2.Message)
require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID
require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID
}
func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Test invalid sequence ID for delete (returns 404 because route doesn't match)
response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil)
require.Equal(t, 404, response.Code)
// Test invalid sequence ID for clear (returns 404 because route doesn't match)
response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil)
require.Equal(t, 404, response.Code)
}
func TestServer_DeleteMessage_WithFirebase(t *testing.T) {
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
// Publish a message
response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil)
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
require.Equal(t, 1, len(sender.Messages()))
require.Equal(t, "message", sender.Messages()[0].Data["event"])
// Delete the message
response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil)
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
require.Equal(t, 2, len(sender.Messages()))
require.Equal(t, "message_delete", sender.Messages()[1].Data["event"])
require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"])
}
func TestServer_ClearMessage_WithFirebase(t *testing.T) {
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
// Publish a message
response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil)
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond)
require.Equal(t, 1, len(sender.Messages()))
// Clear the message
response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil)
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond)
require.Equal(t, 2, len(sender.Messages()))
require.Equal(t, "message_clear", sender.Messages()[1].Data["event"])
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"

View File

@@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
return
}
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON()))
if err != nil {
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
return

View File

@@ -12,10 +12,12 @@ import (
// List of possible events
const (
openEvent = "open"
keepaliveEvent = "keepalive"
messageEvent = "message"
pollRequestEvent = "poll_request"
openEvent = "open"
keepaliveEvent = "keepalive"
messageEvent = "message"
messageDeleteEvent = "message_delete"
messageClearEvent = "message_clear"
pollRequestEvent = "poll_request"
)
const (
@@ -24,10 +26,11 @@ const (
// message represents a message published to a topic
type message struct {
ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above
ID string `json:"id"` // Random message ID
SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
@@ -39,18 +42,19 @@ type message struct {
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
fields := map[string]any{
"topic": m.Topic,
"message_id": m.ID,
"message_time": m.Time,
"message_event": m.Event,
"message_body_size": len(m.Message),
"topic": m.Topic,
"message_id": m.ID,
"message_sequence_id": m.SequenceID,
"message_time": m.Time,
"message_event": m.Event,
"message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String()
@@ -61,6 +65,17 @@ func (m *message) Context() log.Context {
return fields
}
// forJSON returns a copy of the message suitable for JSON output.
// It clears the SequenceID if it equals the ID to reduce redundancy.
func (m *message) forJSON() *message {
if m.SequenceID == m.ID {
clone := *m
clone.SequenceID = ""
return &clone
}
return m
}
type attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
@@ -91,22 +106,23 @@ func newAction() *action {
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Icon string `json:"icon"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Markdown bool `json:"markdown"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
Topic string `json:"topic"`
SequenceID string `json:"sequence_id"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Icon string `json:"icon"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Markdown bool `json:"markdown"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message
@@ -145,6 +161,13 @@ func newPollRequestMessage(topic, pollID string) *message {
return m
}
// newActionMessage creates a new action message (message_delete or message_clear)
func newActionMessage(event, topic, sequenceID string) *message {
m := newMessage(event, topic, "")
m.SequenceID = sequenceID
return m
}
func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
@@ -223,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
}
func (q *queryFilter) Pass(msg *message) bool {
if msg.Event != messageEvent {
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return true // filters only apply to messages
} else if q.ID != "" && msg.ID != q.ID {
return false

View File

@@ -1,3 +1,5 @@
//go:build !nofirebase
package main
import (
@@ -26,7 +28,7 @@ func main() {
}
data[kv[0]] = kv[1]
}
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(*conffile))
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, *conffile))
if err != nil {
fail(err.Error())
}

1514
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -403,5 +403,7 @@
"prefs_appearance_theme_system": "Системна (подразбирана)",
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
"action_bar_unmute_notifications": "Включване звука на известията"
"action_bar_unmute_notifications": "Включване звука на известията",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван"
}

View File

@@ -3,5 +3,14 @@
"action_bar_profile_title": "Perfil",
"action_bar_settings": "Configuració",
"action_bar_account": "Compte",
"common_add": "Afegir"
"common_add": "Afegir",
"common_cancel": "Cancel·la",
"common_save": "Desa",
"common_back": "Enrere",
"common_copy_to_clipboard": "Copia al portaretalls",
"signup_title": "Crea un compte ntfy",
"signup_form_username": "Nom d'usuari",
"signup_form_password": "Contrasenya",
"signup_form_confirm_password": "Confirma la contrasenya",
"signup_form_button_submit": "Dona't d'alta"
}

View File

@@ -0,0 +1,8 @@
{
"common_cancel": "Отмѣнити",
"common_save": "Сохрани",
"common_add": "Приложити",
"common_back": "Назадъ",
"login_form_button_submit": "Въниди",
"signup_form_password": "Таино слово"
}

View File

@@ -403,5 +403,7 @@
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen",
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)"
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden"
}

View File

@@ -1 +1,5 @@
{}
{
"common_cancel": "Nuligi",
"common_save": "Konservi",
"common_add": "Aldoni"
}

View File

@@ -404,5 +404,7 @@
"prefs_appearance_theme_dark": "Oscuro",
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones",
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web"
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un usuario provisionado no se puede editar o eliminar",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado"
}

View File

@@ -139,7 +139,7 @@
"display_name_dialog_placeholder": "Kuvatav nimi",
"publish_dialog_title_no_topic": "Avalda teavitus",
"publish_dialog_progress_uploading": "Laadin üles…",
"publish_dialog_message_published": "Teavitus on saadetud",
"publish_dialog_message_published": "Teavitus on avaldatud",
"publish_dialog_emoji_picker_show": "Vali emoji",
"publish_dialog_priority_low": "Vähetähtis",
"publish_dialog_priority_default": "Vaikimisi tähtsus",
@@ -403,5 +403,7 @@
"account_upgrade_dialog_proration_info": "<strong>Summade jagamine</strong>: Kui muudad teenusepaketti paremaks, siis pead hinnavahe <strong>maksma kohe</strong>. Kui muudad teenusepaketti madalamaks, siis hinnavahe arvelt hüvituvad mõned järgmised maksed.",
"account_upgrade_dialog_reservations_warning_one": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt üks reserveering</strong>. Seda saad <Link>teha siin</Link>.",
"account_upgrade_dialog_reservations_warning_other": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt {{count}} reserveeringut</strong>. Seda saad <Link>teha siin</Link>.",
"prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis."
"prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis.",
"account_basics_cannot_edit_or_delete_provisioned_user": "Eelsisestatud kasutajat ei saa muuta ega kustutada",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada"
}

View File

@@ -15,7 +15,7 @@
"notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers",
"notifications_attachment_open_title": "Aller à {{url}}",
"notifications_attachment_link_expired": "lien de téléchargement expiré",
"nav_button_publish_message": "Publier la notification",
"nav_button_publish_message": "Publier une notification",
"notifications_copied_to_clipboard": "Copié dans le presse-papiers",
"alert_not_supported_title": "Notifications non prises en charge",
"notifications_tags": "Étiquettes",
@@ -64,7 +64,7 @@
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
"publish_dialog_tags_placeholder": "Liste d'étiquettes séparée par des virgules, par ex. avertissement,backup-srv1",
"publish_dialog_priority_label": "Priorité",
"publish_dialog_click_label": "URL du clic",
"publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification",
@@ -272,7 +272,7 @@
"account_delete_dialog_button_submit": "Supprimer définitivement le compte",
"account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.",
"account_upgrade_dialog_title": "Changer le tarif du compte",
"account_upgrade_dialog_proration_info": "<strong>Facturation</strong> : Lors d'un changement vers un tiers payant, la différence de prix sera débitée <strong>immédiatement</strong>. En passant d'un tiers payant a gratuit, votre solde sera utilisé pour payer de futur factures.",
"account_upgrade_dialog_proration_info": "<strong>Proratisation</strong> : Lors d'un changement vers le haut entre plans payants, la différence de prix sera <strong>facturée immédiatement</strong>. En cas de diminutions vers un plan plus économique, la balance sera utilisée pour le paiement des factures suivantes.",
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Paramètres</Link>.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} sujets réservés",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} messages journaliers",
@@ -403,5 +403,7 @@
"web_push_subscription_expiring_title": "Les notifications seront suspendues",
"web_push_subscription_expiring_body": "Ouvrez ntfy pour continuer à recevoir les notifications",
"web_push_unknown_notification_title": "Notification inconnue reçue du serveur",
"web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web"
"web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utilisateur provisionné ne peut pas être modifié ou supprimé",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné"
}

View File

@@ -406,5 +406,7 @@
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada",
"account_basics_cannot_edit_or_delete_provisioned_user": "Unha usuaria predefinida non se pode editar ou eliminar",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Non se pode editar un token de usuaria predefinida"
}

View File

@@ -50,7 +50,7 @@
"publish_dialog_progress_uploading": "Mengunggah …",
"notifications_more_details": "Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.",
"publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notifikasi terpublikasi",
"publish_dialog_message_published": "Notifikasi dipublikasi",
"notifications_loading": "Memuat notifikasi …",
"publish_dialog_base_url_label": "URL Layanan",
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
@@ -71,9 +71,9 @@
"publish_dialog_priority_high": "Prioritas tinggi",
"publish_dialog_priority_max": "Prioritas maksimal",
"publish_dialog_topic_label": "Nama topik",
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
"publish_dialog_message_placeholder": "Tulis pesan di sini",
"publish_dialog_click_label": "Klik URL",
"publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
"publish_dialog_tags_placeholder": "Daftar label yang dipisah dengan tanda koma, contoh: peringatan, cadangan-srv1",
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com",
@@ -404,5 +404,7 @@
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan"
}

View File

@@ -14,7 +14,7 @@
"publish_dialog_title_no_topic": "通知を送信",
"publish_dialog_progress_uploading": "アップロード中…",
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "通知送信しました",
"publish_dialog_message_published": "通知送信済み",
"publish_dialog_title_label": "タイトル",
"publish_dialog_filename_label": "ファイル名",
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
@@ -69,10 +69,10 @@
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
"publish_dialog_priority_high": "優先度 高",
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
"publish_dialog_title_placeholder": "通知タイトル例: ディスクスペース警告",
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
"publish_dialog_tags_label": "タグ",
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください例: warning, srv1-backup",
"publish_dialog_topic_label": "トピック名",
"publish_dialog_delay_label": "遅延",
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
@@ -403,5 +403,7 @@
"prefs_appearance_theme_system": "システム (既定)",
"prefs_appearance_theme_dark": "ダークモード",
"web_push_unknown_notification_title": "不明な通知を受信しました",
"web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります"
"web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります",
"account_basics_cannot_edit_or_delete_provisioned_user": "自動作成されたユーザーの編集や削除はできません",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません"
}

View File

@@ -50,5 +50,47 @@
"nav_topics_title": "Претплатени теми",
"nav_button_all_notifications": "Сите нотификации",
"nav_button_publish_message": "Објави нотификација",
"nav_button_subscribe": "Претплати се на тема"
"nav_button_subscribe": "Претплати се на тема",
"action_bar_unmute_notifications": "Одглуши ги нотификациите",
"action_bar_toggle_mute": "Заглуши/Загуши ги нотификациите",
"message_bar_publish": "Објави порака",
"nav_button_connecting": "се конектира",
"nav_upgrade_banner_label": "Надградете на ntfy Pro",
"nav_upgrade_banner_description": "Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози",
"alert_notification_permission_required_title": "Известувањата се исклучени",
"alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања",
"nav_button_muted": "Известувањата се загушени",
"alert_not_supported_title": "Известувањата не се поддржани",
"alert_not_supported_description": "Известувањата не се поддржани во вашиот прелистувач",
"alert_not_supported_context_description": "Известувањата се поддржани само преку HTTPS. Ова е ограничување на <mdnLink>Notifications API </mdnLink>.",
"notifications_list": "Список на известувања",
"notifications_list_item": "Известување",
"notifications_mark_read": "Означи како прочитано",
"publish_dialog_attached_file_filename_placeholder": "Име на фајл за прилог",
"notifications_attachment_file_app": "Фајл со апликација за Android",
"notifications_attachment_file_document": "друг документ",
"alert_notification_permission_required_button": "Дајте дозвола сега",
"alert_notification_permission_denied_title": "Известувањата се блокирани",
"alert_notification_permission_denied_description": "Ве молиме повторно овозможете ги во вашиот пребарувач",
"alert_notification_ios_install_required_title": "Потребна е инсталација на iOS",
"alert_notification_ios_install_required_description": "Кликнете на иконата Сподели и Додај на почетниот екран за да овозможите известувања на iOS",
"notifications_delete": "Избриши",
"notifications_copied_to_clipboard": "Копирано во таблата со исечоци",
"notifications_tags": "Ознаки",
"notifications_priority_x": "Приоритет {{приоритет}}",
"notifications_new_indicator": "Ново известување",
"notifications_attachment_image": "Слика од прилог",
"notifications_attachment_copy_url_title": "Копирај URL-адресата на прилогот во таблата со исечоци",
"notifications_attachment_open_title": "Оди на {{url}}",
"notifications_attachment_open_button": "Отвори го прилогот",
"notifications_attachment_link_expires": "линкот истекува {{date}}",
"notifications_attachment_link_expired": "линкот за преземање е истечен",
"notifications_attachment_file_image": "слика фајл",
"notifications_attachment_file_video": "видео фајл",
"notifications_attachment_file_audio": "аудио фајл",
"notifications_click_copy_url_button": "Копирај линк",
"notifications_click_open_button": "Отвори линк",
"notifications_actions_open_url_title": "Оди на {{url}}",
"notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата",
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}"
}

View File

@@ -182,7 +182,7 @@
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
"publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
"publish_dialog_title_placeholder": "通知标题,磁盘空间告警",
"publish_dialog_email_label": "电子邮件",
"publish_dialog_button_send": "发送",
"publish_dialog_checkbox_markdown": "格式化为 Markdown",
@@ -203,17 +203,17 @@
"error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
"prefs_users_table": "用户表",
"prefs_users_edit_button": "编辑用户",
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
"publish_dialog_tags_placeholder": "英文逗号分隔的标签列表,例如 warning, srv1-backup",
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}(仅限英语)",
"account_usage_basis_ip_description": "此户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}{{naturalLanguage}} (仅限英语)",
"account_usage_basis_ip_description": "此户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
"account_usage_cannot_create_portal_session": "无法打开计费门户",
"account_delete_title": "删除户",
"account_delete_description": "永久删除您的户",
"account_delete_title": "删除户",
"account_delete_description": "永久删除您的户",
"signup_error_username_taken": "用户名 {{username}} 已被占用",
"signup_error_creation_limit_reached": "已达到户创建限制",
"login_title": "请登录你的 ntfy 户",
"signup_error_creation_limit_reached": "已达到户创建限制",
"login_title": "请登录你的 ntfy 户",
"action_bar_change_display_name": "更改显示名称",
"action_bar_reservation_add": "保留主题",
"action_bar_reservation_delete": "移除保留",
@@ -223,7 +223,7 @@
"action_bar_profile_logout": "登出",
"action_bar_sign_in": "登录",
"action_bar_sign_up": "注册",
"nav_button_account": "户",
"nav_button_account": "户",
"nav_upgrade_banner_label": "升级到 ntfy Pro",
"nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件",
"alert_not_supported_context_description": "通知仅支持 HTTPS。这是 <mdnLink>Notifications API</mdnLink> 的限制。",
@@ -233,7 +233,7 @@
"reserve_dialog_checkbox_label": "保留主题并配置访问",
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名称",
"account_basics_username_description": "嘿,那是你 ❤",
"account_basics_password_description": "更改您的户密码",
"account_basics_password_description": "更改您的户密码",
"account_basics_password_dialog_title": "更改密码",
"account_basics_password_dialog_current_password_label": "当前密码",
"account_basics_password_dialog_new_password_label": "新密码",
@@ -244,8 +244,8 @@
"account_usage_of_limit": "{{limit}} 的",
"account_usage_unlimited": "无限",
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
"account_basics_tier_title": "户类型",
"account_basics_tier_description": "您户的权限级别",
"account_basics_tier_title": "户类型",
"account_basics_tier_description": "您户的权限级别",
"account_basics_tier_admin": "管理员",
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)",
"account_basics_tier_admin_suffix_no_tier": "(无等级)",
@@ -258,7 +258,7 @@
"account_usage_messages_title": "已发布消息",
"account_usage_emails_title": "已发送电子邮件",
"account_usage_reservations_title": "保留主题",
"account_usage_reservations_none": "此户没有保留主题",
"account_usage_reservations_none": "此户没有保留主题",
"account_usage_attachment_storage_title": "附件存储",
"account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除",
"account_upgrade_dialog_button_pay_now": "立即付款并订阅",
@@ -276,7 +276,7 @@
"account_tokens_delete_dialog_title": "删除访问令牌",
"account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 <strong>此操作无法撤消</strong>。",
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
"prefs_users_description_no_sync": "用户和密码不会同步到您的户。",
"prefs_users_description_no_sync": "用户和密码不会同步到您的户。",
"prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户",
"prefs_reservations_title": "保留主题",
"prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。",
@@ -305,13 +305,13 @@
"reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件",
"reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。",
"reservation_delete_dialog_submit_button": "删除保留",
"account_delete_dialog_description": "这将永久删除您的户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。",
"account_delete_dialog_description": "这将永久删除您的户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。",
"account_delete_dialog_label": "密码",
"account_delete_dialog_button_cancel": "取消",
"account_delete_dialog_button_submit": "永久删除户",
"account_delete_dialog_billing_warning": "删除您的户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
"account_upgrade_dialog_title": "更改户等级",
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
"account_delete_dialog_button_submit": "永久删除户",
"account_delete_dialog_billing_warning": "删除您的户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
"account_upgrade_dialog_title": "更改户等级",
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付费计划之间升级时,差价将被<strong>立刻收取</strong>。在降级到较低级别时,余额将被用于支付未来的账单周期。",
"account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 1 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
"account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
@@ -322,30 +322,30 @@
"signup_form_confirm_password": "确认密码",
"signup_form_button_submit": "注册",
"signup_form_toggle_password_visibility": "切换密码可见性",
"signup_title": "创建一个 ntfy 户",
"signup_title": "创建一个 ntfy 户",
"signup_form_username": "用户名",
"signup_form_password": "密码",
"signup_already_have_account": "已有户?登录!",
"signup_already_have_account": "已有户?登录!",
"signup_disabled": "注册已禁用",
"login_form_button_submit": "登录",
"login_link_signup": "注册",
"login_disabled": "登录已禁用",
"action_bar_account": "户",
"action_bar_account": "户",
"action_bar_reservation_edit": "更改保留",
"subscribe_dialog_error_topic_already_reserved": "主题已保留",
"account_basics_title": "户",
"account_basics_title": "户",
"account_basics_username_title": "用户名",
"account_basics_username_admin_tooltip": "你是管理员",
"account_basics_password_title": "密码",
"account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的户将很快被降级。",
"account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费户。",
"account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的户将很快被降级。",
"account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费户。",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间",
"account_upgrade_dialog_tier_selected_label": "已选",
"account_upgrade_dialog_tier_current_label": "当前",
"account_upgrade_dialog_button_cancel": "取消",
"account_upgrade_dialog_button_redirect_signup": "立即注册",
"account_tokens_title": "访问令牌",
"account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的户凭据。查看<Link>文档</Link>以了解更多信息。",
"account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的户凭据。查看<Link>文档</Link>以了解更多信息。",
"account_tokens_table_token_header": "令牌",
"account_tokens_table_label_header": "标签",
"account_tokens_table_last_access_header": "最后访问",
@@ -403,5 +403,7 @@
"web_push_subscription_expiring_title": "通知将被暂停",
"web_push_subscription_expiring_body": "打开ntfy以继续接收通知",
"web_push_unknown_notification_title": "接收到未知通知",
"web_push_unknown_notification_body": "你可能需要打开网页来更新ntfy"
"web_push_unknown_notification_body": "你可能需要打开网页来更新ntfy",
"account_basics_cannot_edit_or_delete_provisioned_user": "已设置的用户无法被编辑或删除",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "无法编辑或删除已设置的令牌"
}

View File

@@ -403,5 +403,7 @@
"web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知",
"web_push_subscription_expiring_title": "通知會被暫停",
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy",
"web_push_unknown_notification_title": "接收到不明通知"
"web_push_unknown_notification_title": "接收到不明通知",
"account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖"
}

View File

@@ -3,11 +3,16 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
import {
EVENT_MESSAGE,
EVENT_MESSAGE_CLEAR,
EVENT_MESSAGE_DELETE,
WEBPUSH_EVENT_MESSAGE,
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
} from "../src/app/events";
/**
* General docs for service workers and PWAs:
@@ -21,25 +26,6 @@ import initI18n from "../src/app/i18n";
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
const addNotification = async ({ subscriptionId, message }) => {
const db = await dbAsync();
await db.notifications.add({
...message,
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
});
await db.subscriptions.update(subscriptionId, {
last: message.id,
});
const badgeCount = await db.notifications.where({ new: 1 }).count();
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
self.navigator.setAppBadge?.(badgeCount);
};
/**
* Handle a received web push message and show notification.
*
@@ -48,10 +34,35 @@ const addNotification = async ({ subscriptionId, message }) => {
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
broadcastChannel.postMessage(message); // To potentially play sound
console.log("[ServiceWorker] Message received", data);
// Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete();
}
// Add notification to database
await db.notifications.add({
...messageWithSequenceId(message),
subscriptionId,
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
});
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {
last: message.id,
});
// Update badge in PWA
const badgeCount = await db.notifications.where({ new: 1 }).count();
self.navigator.setAppBadge?.(badgeCount);
// Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message);
await addNotification({ subscriptionId, message });
await self.registration.showNotification(
...toNotificationParams({
subscriptionId,
@@ -62,11 +73,70 @@ const handlePushMessage = async (data) => {
);
};
/**
* Handle a message_delete event: delete the notification from the database.
*/
const handlePushMessageDelete = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Deleting notification sequence", data);
// Delete notification with the same sequence_id
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete();
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {
last: message.id,
});
};
/**
* Handle a message_clear event: clear/dismiss the notification.
*/
const handlePushMessageClear = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Marking notification as read", data);
// Mark notification as read (set new = 0)
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {
last: message.id,
});
// Update badge count
const badgeCount = await db.notifications.where({ new: 1 }).count();
self.navigator.setAppBadge?.(badgeCount);
};
/**
* Handle a received web push subscription expiring.
*/
const handlePushSubscriptionExpiring = async (data) => {
const t = await initI18n();
console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
body: t("web_push_subscription_expiring_body"),
@@ -82,6 +152,7 @@ const handlePushSubscriptionExpiring = async (data) => {
*/
const handlePushUnknown = async (data) => {
const t = await initI18n();
console.log("[ServiceWorker] Unknown event received", data);
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
body: t("web_push_unknown_notification_body"),
@@ -96,13 +167,26 @@ const handlePushUnknown = async (data) => {
* @param {object} data see server/types.go, type webPushPayload
*/
const handlePush = async (data) => {
if (data.event === "message") {
await handlePushMessage(data);
} else if (data.event === "subscription_expiring") {
await handlePushSubscriptionExpiring(data);
} else {
await handlePushUnknown(data);
// This logic is (partially) duplicated in
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (data.event === WEBPUSH_EVENT_MESSAGE) {
const { message } = data;
if (message.event === EVENT_MESSAGE) {
return await handlePushMessage(data);
} else if (message.event === EVENT_MESSAGE_DELETE) {
return await handlePushMessageDelete(data);
} else if (message.event === EVENT_MESSAGE_CLEAR) {
return await handlePushMessageClear(data);
}
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
return await handlePushSubscriptionExpiring(data);
}
return await handlePushUnknown(data);
};
/**
@@ -113,10 +197,8 @@ const handleClick = async (event) => {
const t = await initI18n();
const clients = await self.clients.matchAll({ type: "window" });
const rootUrl = new URL(self.location.origin);
const rootClient = clients.find((client) => client.url === rootUrl.toString());
// perhaps open on another topic
const fallbackClient = clients[0];
if (!event.notification.data?.message) {
@@ -232,6 +314,7 @@ precacheAndRoute(
// Claim all open windows
clientsClaim();
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();

View File

@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
import { EVENT_OPEN, isNotificationEvent } from "./events";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
@@ -48,10 +49,11 @@ class Connection {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
try {
const data = JSON.parse(event.data);
if (data.event === "open") {
if (data.event === EVENT_OPEN) {
return;
}
const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
// Accept message, message_delete, and message_clear events
const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data;
if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
return;

View File

@@ -31,6 +31,21 @@ class Notifier {
);
}
async cancel(notification) {
if (!this.supported()) {
return;
}
try {
const tag = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${tag}`);
const registration = await this.serviceWorkerRegistration();
const notifications = await registration.getNotifications({ tag });
notifications.forEach((n) => n.close());
} catch (e) {
console.log(`[Notifier] Error cancelling notification`, e);
}
}
async playSound() {
// Play sound
const sound = await prefs.sound();

View File

@@ -1,5 +1,7 @@
import api from "./Api";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events";
const delayMillis = 2000; // 2 seconds
const intervalMillis = 300000; // 5 minutes
@@ -42,12 +44,35 @@ class Poller {
const since = subscription.last;
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
if (!notifications || notifications.length === 0) {
console.log(`[Poller] No new notifications found for ${subscription.id}`);
return;
// Filter out notifications older than the prune threshold
const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0;
const recentNotifications =
pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications;
// Find the latest notification for each sequence ID
const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications);
// Delete all existing notifications for which the latest notification is marked as deleted
const deletedSequenceIds = Object.entries(latestBySequenceId)
.filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE)
.map(([sequenceId]) => sequenceId);
if (deletedSequenceIds.length > 0) {
console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
await Promise.all(
deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
);
}
// Add only the latest notification for each non-deleted sequence
const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE);
if (notificationsToAdd.length > 0) {
console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);
await subscriptionManager.addNotifications(subscription.id, notificationsToAdd);
} else {
console.log(`[Poller] No new notifications found for ${subscription.id}`);
}
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
await subscriptionManager.addNotifications(subscription.id, notifications);
}
pollInBackground(subscription) {
@@ -59,6 +84,21 @@ class Poller {
}
})();
}
/**
* Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.
* Returns an object mapping sequenceId -> latest notification.
*/
latestNotificationsBySequenceId(notifications) {
const latestBySequenceId = {};
notifications.forEach((notification) => {
const sequenceId = notification.sequence_id || notification.id;
if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {
latestBySequenceId[sequenceId] = notification;
}
});
return latestBySequenceId;
}
}
const poller = new Poller();

View File

@@ -3,6 +3,8 @@ import notifier from "./Notifier";
import prefs from "./Prefs";
import db from "./db";
import { topicUrl } from "./utils";
import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
constructor(dbImpl) {
@@ -48,16 +50,17 @@ class SubscriptionManager {
}
async notify(subscriptionId, notification) {
if (notification.event !== EVENT_MESSAGE) {
return;
}
const subscription = await this.get(subscriptionId);
if (subscription.mutedUntil > 0) {
return;
}
const priority = notification.priority ?? 3;
if (priority < (await prefs.minPriority())) {
return;
}
await notifier.notify(subscription, notification);
}
@@ -157,7 +160,7 @@ class SubscriptionManager {
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return this.db.notifications
.orderBy("time") // Sort by time first
.orderBy("time") // Sort by time
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
@@ -173,17 +176,22 @@ class SubscriptionManager {
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await this.db.notifications.get(notification.id);
if (exists) {
if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
return false;
}
try {
// sw.js duplicates this logic, so if you change it here, change it there too
// Note: Service worker (sw.js) and addNotifications() duplicates this logic,
// so if you change it here, change it there too.
// Add notification to database
await this.db.notifications.add({
...notification,
...messageWithSequenceId(notification),
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
}); // FIXME consider put() for double tab
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
});
// FIXME consider put() for double tab
// Update subscription last message id (for ?since=... queries)
await this.db.subscriptions.update(subscriptionId, {
last: notification.id,
});
@@ -195,7 +203,10 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
const notificationsWithSubscriptionId = notifications.map((notification) => ({
...messageWithSequenceId(notification),
subscriptionId,
}));
const lastNotificationId = notifications.at(-1).id;
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, {
@@ -220,6 +231,10 @@ class SubscriptionManager {
await this.db.notifications.delete(notificationId);
}
async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
}
async deleteNotifications(subscriptionId) {
await this.db.notifications.where({ subscriptionId }).delete();
}
@@ -228,6 +243,10 @@ class SubscriptionManager {
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
async markNotificationReadBySequenceId(subscriptionId, sequenceId) {
await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
}
async markNotificationsRead(subscriptionId) {
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
}

View File

@@ -11,13 +11,20 @@ const createDatabase = (username) => {
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
const db = new Dexie(dbName);
db.version(2).stores({
db.version(3).stores({
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
users: "&baseUrl,username",
prefs: "&key",
});
// When another connection (e.g., service worker or another tab) wants to upgrade,
// close this connection gracefully to allow the upgrade to proceed
db.on("versionchange", () => {
console.log("[db] versionchange event: closing database");
db.close();
});
return db;
};

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

@@ -0,0 +1,15 @@
// Event types for ntfy messages
// These correspond to the server event types in server/types.go
export const EVENT_OPEN = "open";
export const EVENT_KEEPALIVE = "keepalive";
export const EVENT_MESSAGE = "message";
export const EVENT_MESSAGE_DELETE = "message_delete";
export const EVENT_MESSAGE_CLEAR = "message_clear";
export const EVENT_POLL_REQUEST = "poll_request";
export const WEBPUSH_EVENT_MESSAGE = "message";
export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
// Check if an event is a notification event (message, delete, or read)
export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;

View File

@@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => {
export const formatMessage = (m) => {
if (m.title) {
return m.message;
return m.message || "";
}
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
return `${emojiList.join(" ")} ${m.message || ""}`;
}
return m.message;
return m.message || "";
};
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
@@ -50,7 +50,7 @@ export const isImage = (attachment) => {
export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg";
export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => {
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
const image = isImage(message.attachment) ? message.attachment.url : undefined;
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
@@ -61,8 +61,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
badge,
icon,
image,
timestamp: message.time * 1_000,
tag: subscriptionId,
timestamp: message.time * 1000,
tag: message.sequence_id || message.id, // Update notification if there is a sequence ID
renotify: true,
silent: false,
// This is used by the notification onclick event
@@ -79,3 +79,10 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
},
];
};
export const messageWithSequenceId = (message) => {
if (message.sequenceId) {
return message;
}
return { ...message, sequenceId: message.sequence_id || message.id };
};

View File

@@ -12,6 +12,7 @@ import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -50,9 +51,28 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
};
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
await subscriptionManager.notify(subscriptionId, notification);
// This logic is (partially) duplicated in
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
} else {
// Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id;
if (sequenceId) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId);
}
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
await subscriptionManager.notify(subscriptionId, notification);
}
}
};
@@ -231,7 +251,9 @@ export const useIsLaunchedPWA = () => {
useEffect(() => {
if (isIOSStandalone) {
return () => {}; // No need to listen for events on iOS
return () => {
// No need to listen for events on iOS
};
}
const handler = (evt) => {
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);

View File

@@ -5,10 +5,19 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
const intervalMS = 60 * 60 * 1000;
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
const registerSW = () =>
const registerSW = () => {
console.log("[ServiceWorker] Registering service worker");
if (!("serviceWorker" in navigator)) {
console.warn("[ServiceWorker] Service workers not supported");
return;
}
viteRegisterSW({
onRegisteredSW(swUrl, registration) {
console.log("[ServiceWorker] Registered:", { swUrl, registration });
if (!registration) {
console.warn("[ServiceWorker] No registration returned");
return;
}
@@ -23,9 +32,16 @@ const registerSW = () =>
},
});
if (resp?.status === 200) await registration.update();
if (resp?.status === 200) {
console.log("[ServiceWorker] Updating service worker");
await registration.update();
}
}, intervalMS);
},
onRegisterError(error) {
console.error("[ServiceWorker] Registration error:", error);
},
});
};
export default registerSW;