mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-18 16:17:26 +01:00
Compare commits
80 Commits
admin-ui
...
a0ebd64461
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ebd64461 | ||
|
|
dafd130fe5 | ||
|
|
11e9e1e6a0 | ||
|
|
b23f6632b1 | ||
|
|
6bacf7dafc | ||
|
|
0e200b96e0 | ||
|
|
3ce56879ae | ||
|
|
48efdffa57 | ||
|
|
9135bb277b | ||
|
|
711899ad35 | ||
|
|
01435d5fea | ||
|
|
8ce2188b28 | ||
|
|
c1ee163cab | ||
|
|
fcf57a04e1 | ||
|
|
0ad06e808f | ||
|
|
2cc4bf7d28 | ||
|
|
5ae3774f19 | ||
|
|
94eb121f38 | ||
|
|
e81be48bf3 | ||
|
|
db4a4776d3 | ||
|
|
3d54260f79 | ||
|
|
a712d78e4c | ||
|
|
ea3008c707 | ||
|
|
2e39a1329c | ||
|
|
ff2b8167b4 | ||
|
|
96638b516c | ||
|
|
dd9b36cf0a | ||
|
|
44f20f6b4c | ||
|
|
a3c16d81f8 | ||
|
|
c0a5a1fb35 | ||
|
|
b5f24b12dc | ||
|
|
898a52ec63 | ||
|
|
7066ba92a6 | ||
|
|
8960459520 | ||
|
|
f581af6d27 | ||
|
|
88016a2549 | ||
|
|
a7603d1dfb | ||
|
|
65e2af9efb | ||
|
|
090cd720c1 | ||
|
|
7bc8edf7c3 | ||
|
|
13768d4289 | ||
|
|
37d71051de | ||
|
|
5ad3de2904 | ||
|
|
66ea25c18b | ||
|
|
1ab7ca876c | ||
|
|
fd8cd5ca91 | ||
|
|
7cffbfcd6d | ||
|
|
dffee9ea7d | ||
|
|
239959e2a4 | ||
|
|
75abf2e245 | ||
|
|
bfbe73aea3 | ||
|
|
b3721c1b71 | ||
|
|
544f7ef1cb | ||
|
|
2dd152df3f | ||
|
|
2856793eff | ||
|
|
16bec00b38 | ||
|
|
f51e99dc80 | ||
|
|
aca9a77498 | ||
|
|
1c2550d749 | ||
|
|
1c32ee7613 | ||
|
|
f356309f70 | ||
|
|
39936a95f8 | ||
|
|
e18d64933f | ||
|
|
20ccee5d7d | ||
|
|
3575abcde3 | ||
|
|
2cd444ece0 | ||
|
|
eca1ed4d8d | ||
|
|
4ce9508fd3 | ||
|
|
4f9f1292f1 | ||
|
|
d64376c5ac | ||
|
|
275487b3fd | ||
|
|
9301546e6d | ||
|
|
49b3c724cf | ||
|
|
7091775234 | ||
|
|
90667e7176 | ||
|
|
8293a24cf9 | ||
|
|
83b5470bc5 | ||
|
|
2aae3577cb | ||
|
|
16900d2c10 | ||
|
|
950ba1e2e1 |
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
10
cmd/serve.go
10
cmd/serve.go
@@ -14,6 +14,7 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -77,6 +78,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
@@ -187,6 +189,7 @@ func execServe(c *cli.Context) error {
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||
twilioVerifyService := c.String("twilio-verify-service")
|
||||
twilioCallFormat := c.String("twilio-call-format")
|
||||
messageSizeLimitStr := c.String("message-size-limit")
|
||||
messageDelayLimitStr := c.String("message-delay-limit")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
@@ -456,6 +459,13 @@ func execServe(c *cli.Context) error {
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||
conf.TwilioVerifyService = twilioVerifyService
|
||||
if twilioCallFormat != "" {
|
||||
tmpl, err := template.New("twiml").Parse(twilioCallFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
||||
}
|
||||
conf.TwilioCallFormat = tmpl
|
||||
}
|
||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||
conf.MessageDelayMax = messageDelayLimit
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
|
||||
@@ -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 {
|
||||
@@ -1034,7 +1034,7 @@ or the root domain:
|
||||
``` kdl
|
||||
// /etc/ferron.kdl
|
||||
// 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: Ferron automatically handles both HTTP and WebSockets with proxy
|
||||
|
||||
ntfy.sh {
|
||||
@@ -1261,10 +1261,85 @@ are the easiest), and then configure the following options:
|
||||
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
|
||||
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)
|
||||
|
||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||
|
||||
To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is
|
||||
rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:
|
||||
|
||||
* `{{.Topic}}` is the topic name
|
||||
* `{{.Message}}` is the message body
|
||||
* `{{.Title}}` is the message title
|
||||
* `{{.Tags}}` is a list of tags
|
||||
* `{{.Priority}}` is the message priority
|
||||
* `{{.Sender}}` is the IP address or username of the sender
|
||||
|
||||
Here's an example:
|
||||
|
||||
=== "Custom TwiML (English)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
Yo yo yo, you should totally check out this message for {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
It's really really important, dude. So listen up!
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Bro, it's titled: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
That is all.
|
||||
<break time="1s"/>
|
||||
You know who this message is from? It is from {{.Sender}}.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>See ya!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
=== "Custom TwiML (German)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3" voice="alice" language="de-DE">
|
||||
Du hast eine Nachricht zum Thema {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
Achtung. Die Nachricht ist sehr wichtig.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Titel der Nachricht: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say voice="alice" language="de-DE">Alla mol!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
## Message limits
|
||||
There are a few message limits that you can configure:
|
||||
|
||||
|
||||
46
docs/contact.md
Normal file
46
docs/contact.md
Normal 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
43
docs/contributing.md
Normal 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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
10
docs/faq.md
10
docs/faq.md
@@ -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.
|
||||
|
||||
@@ -90,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
||||
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
||||
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||
@@ -181,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
|
||||
|
||||
|
||||
198
docs/privacy.md
198
docs/privacy.md
@@ -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).
|
||||
|
||||
473
docs/publish.md
473
docs/publish.md
@@ -937,6 +937,445 @@ Here's an example with a custom message, tags and a priority:
|
||||
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
||||
```
|
||||
|
||||
## Updating + deleting notifications
|
||||
_Supported on:_ :material-android: :material-firefox:
|
||||
|
||||
!!! info
|
||||
**This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later.
|
||||
|
||||
You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
|
||||
like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
|
||||
|
||||
* [Updating notifications](#updating-notifications) will alter the content of an existing notification.
|
||||
* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.
|
||||
* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).
|
||||
|
||||
Here's an example of a download progress notification being updated over time on Android:
|
||||
|
||||
<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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,14 +4,41 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------------------------------|---------|--------------|
|
||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
||||
| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
| 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
|
||||
|
||||
@@ -1572,18 +1599,31 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android app v1.21.x
|
||||
### ntfy server v2.16.x (UNRELEASED)
|
||||
|
||||
**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))
|
||||
* 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 a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) 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:**
|
||||
|
||||
* 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))
|
||||
* 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
|
||||
|
||||
18
docs/static/css/extra.css
vendored
18
docs/static/css/extra.css
vendored
@@ -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;
|
||||
|
||||
BIN
docs/static/img/android-screenshot-notification-update-1.png
vendored
Normal file
BIN
docs/static/img/android-screenshot-notification-update-1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-notification-update-2.png
vendored
Normal file
BIN
docs/static/img/android-screenshot-notification-update-2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -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):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -129,3 +129,15 @@ keyboard.
|
||||
|
||||
## iOS app
|
||||
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
|
||||
|
||||
## Other
|
||||
|
||||
### "Reconnecting..." / Late notifications on mobile (self-hosted)
|
||||
|
||||
If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:
|
||||
|
||||
* If ntfy is behind a reverse proxy (such as Nginx):
|
||||
* Make sure `behind-proxy` is enabled in ntfy's config.
|
||||
* Make sure WebSockets are enabled in the reverse proxy config.
|
||||
* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.
|
||||
* In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.
|
||||
|
||||
32
go.mod
32
go.mod
@@ -5,23 +5,23 @@ go 1.24.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||
cloud.google.com/go/storage v1.58.0 // indirect
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.59.1 // 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.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.46.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.258.0
|
||||
google.golang.org/api v0.260.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ 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.32.0
|
||||
golang.org/x/text v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -45,7 +45,7 @@ require (
|
||||
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
|
||||
@@ -69,14 +69,14 @@ 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/enterprise-certificate-proxy v0.3.11 // 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.4 // 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
|
||||
@@ -92,13 +92,13 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.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-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // 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
|
||||
)
|
||||
|
||||
64
go.sum
64
go.sum
@@ -8,18 +8,18 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
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=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
||||
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
||||
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58=
|
||||
cloud.google.com/go/storage v1.59.1/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=
|
||||
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
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=
|
||||
@@ -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.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -200,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
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,18 +263,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||
google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
|
||||
google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
@@ -128,6 +129,7 @@ type Config struct {
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
@@ -226,6 +228,7 @@ func NewConfig() *Config {
|
||||
TwilioPhoneNumber: "",
|
||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||
TwilioVerifyService: "",
|
||||
TwilioCallFormat: nil,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,7 +29,9 @@ const (
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
sequence_id TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
expires INT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
@@ -52,6 +54,7 @@ const (
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
@@ -66,50 +69,50 @@ const (
|
||||
COMMIT;
|
||||
`
|
||||
insertMessageQuery = `
|
||||
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesByIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE mid = ?
|
||||
`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesLatestQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND published = 1
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
@@ -131,7 +134,7 @@ const (
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 13
|
||||
currentSchemaVersion = 14
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -260,6 +263,13 @@ const (
|
||||
migrate12To13AlterMessagesTableQuery = `
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
`
|
||||
|
||||
//13 -> 14
|
||||
migrate13To14AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -277,6 +287,7 @@ var (
|
||||
10: migrateFrom10,
|
||||
11: migrateFrom11,
|
||||
12: migrateFrom12,
|
||||
13: migrateFrom13,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -369,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, m := range ms {
|
||||
if m.Event != messageEvent {
|
||||
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
@@ -397,7 +408,9 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||
}
|
||||
_, err := stmt.Exec(
|
||||
m.ID,
|
||||
m.SequenceID,
|
||||
m.Time,
|
||||
m.Event,
|
||||
m.Expires,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
@@ -706,10 +719,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
func readMessage(rows *sql.Rows) (*message, error) {
|
||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&sequenceID,
|
||||
×tamp,
|
||||
&event,
|
||||
&expires,
|
||||
&topic,
|
||||
&msg,
|
||||
@@ -758,9 +773,10 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||
}
|
||||
return &message{
|
||||
ID: id,
|
||||
SequenceID: sequenceID,
|
||||
Time: timestamp,
|
||||
Expires: expires,
|
||||
Event: messageEvent,
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
@@ -1030,3 +1046,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func migrateFrom13(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(updateSchemaVersion, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
server/server.go
107
server/server.go
@@ -80,11 +80,12 @@ var (
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
webRootHTMLPath = "/app.html"
|
||||
webServiceWorkerPath = "/sw.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
@@ -108,7 +109,7 @@ var (
|
||||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
@@ -137,7 +138,7 @@ var (
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
emptyMessageBody = "triggered" // Used when a message body is empty
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
@@ -531,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
return s.handleMetrics(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
@@ -543,8 +544,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||
@@ -872,7 +877,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
return s.writeJSON(w, m)
|
||||
return s.writeJSON(w, m.forJSON())
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -900,6 +905,58 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageDeleteEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageClearEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vrate, err := fromContext[*visitor](r, contextRateVisitor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
|
||||
return errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
}
|
||||
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
|
||||
if e != nil {
|
||||
return e.With(t)
|
||||
}
|
||||
// Create an action message with the given event type
|
||||
m := newActionMessage(event, t.ID, sequenceID)
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
// Publish to subscribers
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send to Firebase for Android clients
|
||||
if s.firebaseClient != nil {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
// Send to web push endpoints
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
// Add to message cache
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return err
|
||||
}
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
return s.writeJSON(w, m.forJSON())
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
@@ -957,6 +1014,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
|
||||
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
|
||||
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, err
|
||||
}
|
||||
m.SequenceID = pathSequenceID
|
||||
} else {
|
||||
sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
|
||||
if sequenceID != "" {
|
||||
if sequenceIDRegex.MatchString(sequenceID) {
|
||||
m.SequenceID = sequenceID
|
||||
} else {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
} else {
|
||||
m.SequenceID = m.ID
|
||||
}
|
||||
}
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -1271,7 +1346,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1282,10 +1357,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != messageEvent {
|
||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
||||
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
|
||||
}
|
||||
return fmt.Sprintf("data: %s\n", buf.String()), nil
|
||||
@@ -1695,6 +1770,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
||||
return topics, parts[1], nil
|
||||
}
|
||||
|
||||
// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
|
||||
func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 3 {
|
||||
return "", errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
return parts[2], nil
|
||||
}
|
||||
|
||||
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
|
||||
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
s.mu.Lock()
|
||||
@@ -1949,6 +2033,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
if m.Firebase != "" {
|
||||
r.Header.Set("X-Firebase", m.Firebase)
|
||||
}
|
||||
if m.SequenceID != "" {
|
||||
r.Header.Set("X-Sequence-ID", m.SequenceID)
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +216,13 @@
|
||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
|
||||
#
|
||||
# twilio-account:
|
||||
# twilio-auth-token:
|
||||
# twilio-phone-number:
|
||||
# twilio-verify-service:
|
||||
# twilio-call-format:
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
|
||||
@@ -24,17 +24,15 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
||||
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
|
||||
for i, g := range grants[u.ID] {
|
||||
userGrants[i] = &apiUserGrantResponse{
|
||||
Topic: g.TopicPattern,
|
||||
Permission: g.Permission.String(),
|
||||
Provisioned: g.Provisioned,
|
||||
Topic: g.TopicPattern,
|
||||
Permission: g.Permission.String(),
|
||||
}
|
||||
}
|
||||
usersResponse[i] = &apiUserResponse{
|
||||
Username: u.Name,
|
||||
Role: string(u.Role),
|
||||
Tier: tier,
|
||||
Grants: userGrants,
|
||||
Provisioned: u.Provisioned,
|
||||
Username: u.Name,
|
||||
Role: string(u.Role),
|
||||
Tier: tier,
|
||||
Grants: userGrants,
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, usersResponse)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,33 +4,49 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioCallFormat = `
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic %s. Message:
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
%s
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user %s. It will be repeated three times.
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`
|
||||
)
|
||||
</Response>`))
|
||||
|
||||
// twilioCallData holds the data passed to the Twilio call format template
|
||||
type twilioCallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
@@ -65,7 +81,29 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if s.config.TwilioCallFormat != nil {
|
||||
tmpl = s.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
templateData := &twilioCallData{
|
||||
Topic: xmlEscapeText(m.Topic),
|
||||
Title: xmlEscapeText(m.Title),
|
||||
Message: xmlEscapeText(m.Message),
|
||||
Priority: m.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(sender),
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
|
||||
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
||||
@@ -202,6 +204,67 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say language="de-DE" loop="3">
|
||||
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say language="de-DE">Auf Wiederhören.</Say>
|
||||
</Response>`))
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -308,17 +331,15 @@ type apiUserAddOrUpdateRequest struct {
|
||||
}
|
||||
|
||||
type apiUserResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
type apiUserGrantResponse struct {
|
||||
Topic string `json:"topic"` // This may be a pattern
|
||||
Permission string `json:"permission"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Topic string `json:"topic"` // This may be a pattern
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type apiUserDeleteRequest struct {
|
||||
|
||||
825
web/package-lock.json
generated
825
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -403,5 +403,7 @@
|
||||
"prefs_appearance_theme_light": "Světlý režim",
|
||||
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
|
||||
"web_push_unknown_notification_title": "Neznámé oznámení přijaté ze serveru",
|
||||
"web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace"
|
||||
"web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Přiděleného uživatele nelze upravovat ani odstranit",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Nelze upravit ani odstranit přidělený token"
|
||||
}
|
||||
|
||||
@@ -405,48 +405,5 @@
|
||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||
"web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications",
|
||||
"web_push_unknown_notification_title": "Unknown notification received from server",
|
||||
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app",
|
||||
"nav_button_admin": "Admin",
|
||||
"admin_users_title": "Users",
|
||||
"admin_users_description": "Manage users and their access permissions. Admin users cannot be modified via the web interface.",
|
||||
"admin_users_table_username_header": "Username",
|
||||
"admin_users_table_role_header": "Role",
|
||||
"admin_users_table_tier_header": "Tier",
|
||||
"admin_users_table_grants_header": "Access grants",
|
||||
"admin_users_table_actions_header": "Actions",
|
||||
"admin_users_table_grant_tooltip": "Permission: {{permission}}",
|
||||
"admin_users_table_grant_provisioned_tooltip": "Permission: {{permission}} (provisioned, cannot be changed)",
|
||||
"admin_users_table_add_access_tooltip": "Add access grant",
|
||||
"admin_users_table_edit_tooltip": "Edit user",
|
||||
"admin_users_table_delete_tooltip": "Delete user",
|
||||
"admin_users_table_admin_no_actions": "Cannot modify admin users",
|
||||
"admin_users_provisioned_tooltip": "Provisioned user (defined in server config)",
|
||||
"admin_users_provisioned_cannot_edit": "Provisioned users cannot be edited or deleted",
|
||||
"admin_users_role_admin": "Admin",
|
||||
"admin_users_role_user": "User",
|
||||
"admin_users_add_button": "Add user",
|
||||
"admin_users_add_dialog_title": "Add user",
|
||||
"admin_users_add_dialog_username_label": "Username",
|
||||
"admin_users_add_dialog_password_label": "Password",
|
||||
"admin_users_add_dialog_tier_label": "Tier",
|
||||
"admin_users_add_dialog_tier_helper": "Optional. Leave empty for no tier.",
|
||||
"admin_users_edit_dialog_title": "Edit user {{username}}",
|
||||
"admin_users_edit_dialog_password_label": "New password",
|
||||
"admin_users_edit_dialog_password_helper": "Leave empty to keep current password",
|
||||
"admin_users_edit_dialog_tier_label": "Tier",
|
||||
"admin_users_edit_dialog_tier_helper": "Leave empty to keep current tier",
|
||||
"admin_users_delete_dialog_title": "Delete user",
|
||||
"admin_users_delete_dialog_description": "Are you sure you want to delete user {{username}}? This action cannot be undone.",
|
||||
"admin_users_delete_dialog_button": "Delete user",
|
||||
"admin_access_add_dialog_title": "Add access for {{username}}",
|
||||
"admin_access_add_dialog_topic_label": "Topic",
|
||||
"admin_access_add_dialog_topic_helper": "Topic name or pattern (e.g. mytopic or alerts-*)",
|
||||
"admin_access_add_dialog_permission_label": "Permission",
|
||||
"admin_access_permission_read_write": "Read & Write",
|
||||
"admin_access_permission_read_only": "Read only",
|
||||
"admin_access_permission_write_only": "Write only",
|
||||
"admin_access_permission_deny_all": "Deny all",
|
||||
"admin_access_delete_dialog_title": "Remove access",
|
||||
"admin_access_delete_dialog_description": "Are you sure you want to remove access to topic {{topic}} for user {{username}}?",
|
||||
"admin_access_delete_dialog_button": "Remove access"
|
||||
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@
|
||||
"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 dipublikasikan",
|
||||
"notifications_loading": "Memuat notifikasi …",
|
||||
"publish_dialog_base_url_label": "URL Layanan",
|
||||
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
|
||||
"publish_dialog_title_placeholder": "Judul notifikasi, contoh: Peringatan ruang penyimpanan disk",
|
||||
"publish_dialog_tags_label": "Tanda",
|
||||
"publish_dialog_priority_label": "Prioritas",
|
||||
"publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com",
|
||||
@@ -71,12 +71,12 @@
|
||||
"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 dipisahkan 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",
|
||||
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, contoh: phil@example.com",
|
||||
"publish_dialog_attach_label": "URL Lampiran",
|
||||
"publish_dialog_filename_label": "Nama File",
|
||||
"publish_dialog_filename_placeholder": "Nama file lampiran",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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": "无法编辑或删除已设置的令牌"
|
||||
}
|
||||
|
||||
@@ -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": "無法編輯或刪除已佈建的權杖"
|
||||
}
|
||||
|
||||
147
web/public/sw.js
147
web/public/sw.js
@@ -3,11 +3,16 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
|
||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||
import { NetworkFirst } from "workbox-strategies";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
|
||||
import { dbAsync } from "../src/app/db";
|
||||
|
||||
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
|
||||
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
|
||||
import initI18n from "../src/app/i18n";
|
||||
import {
|
||||
EVENT_MESSAGE,
|
||||
EVENT_MESSAGE_CLEAR,
|
||||
EVENT_MESSAGE_DELETE,
|
||||
WEBPUSH_EVENT_MESSAGE,
|
||||
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
|
||||
} from "../src/app/events";
|
||||
|
||||
/**
|
||||
* General docs for service workers and PWAs:
|
||||
@@ -21,25 +26,6 @@ import initI18n from "../src/app/i18n";
|
||||
|
||||
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||
|
||||
const addNotification = async ({ subscriptionId, message }) => {
|
||||
const db = await dbAsync();
|
||||
|
||||
await db.notifications.add({
|
||||
...message,
|
||||
subscriptionId,
|
||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
new: 1,
|
||||
});
|
||||
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a received web push message and show notification.
|
||||
*
|
||||
@@ -48,10 +34,35 @@ const addNotification = async ({ subscriptionId, message }) => {
|
||||
*/
|
||||
const handlePushMessage = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
|
||||
broadcastChannel.postMessage(message); // To potentially play sound
|
||||
console.log("[ServiceWorker] Message received", data);
|
||||
|
||||
// Delete existing notification with same sequence ID (if any)
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
// Add notification to database
|
||||
await db.notifications.add({
|
||||
...messageWithSequenceId(message),
|
||||
subscriptionId,
|
||||
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
});
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
// Update badge in PWA
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
|
||||
// Broadcast the message to potentially play a sound
|
||||
broadcastChannel.postMessage(message);
|
||||
|
||||
await addNotification({ subscriptionId, message });
|
||||
await self.registration.showNotification(
|
||||
...toNotificationParams({
|
||||
subscriptionId,
|
||||
@@ -62,11 +73,70 @@ const handlePushMessage = async (data) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a message_delete event: delete the notification from the database.
|
||||
*/
|
||||
const handlePushMessageDelete = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Deleting notification sequence", data);
|
||||
|
||||
// Delete notification with the same sequence_id
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag
|
||||
const tag = message.sequence_id || message.id;
|
||||
if (tag) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
}
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a message_clear event: clear/dismiss the notification.
|
||||
*/
|
||||
const handlePushMessageClear = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Marking notification as read", data);
|
||||
|
||||
// Mark notification as read (set new = 0)
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag
|
||||
const tag = message.sequence_id || message.id;
|
||||
if (tag) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
}
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
// Update badge count
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a received web push subscription expiring.
|
||||
*/
|
||||
const handlePushSubscriptionExpiring = async (data) => {
|
||||
const t = await initI18n();
|
||||
console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
|
||||
|
||||
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
|
||||
body: t("web_push_subscription_expiring_body"),
|
||||
@@ -82,6 +152,7 @@ const handlePushSubscriptionExpiring = async (data) => {
|
||||
*/
|
||||
const handlePushUnknown = async (data) => {
|
||||
const t = await initI18n();
|
||||
console.log("[ServiceWorker] Unknown event received", data);
|
||||
|
||||
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
|
||||
body: t("web_push_unknown_notification_body"),
|
||||
@@ -96,13 +167,26 @@ const handlePushUnknown = async (data) => {
|
||||
* @param {object} data see server/types.go, type webPushPayload
|
||||
*/
|
||||
const handlePush = async (data) => {
|
||||
if (data.event === "message") {
|
||||
await handlePushMessage(data);
|
||||
} else if (data.event === "subscription_expiring") {
|
||||
await handlePushSubscriptionExpiring(data);
|
||||
} else {
|
||||
await handlePushUnknown(data);
|
||||
// This logic is (partially) duplicated in
|
||||
// - Android: SubscriberService::onNotificationReceived()
|
||||
// - Android: FirebaseService::onMessageReceived()
|
||||
// - Web app: hooks.js:handleNotification()
|
||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||
|
||||
if (data.event === WEBPUSH_EVENT_MESSAGE) {
|
||||
const { message } = data;
|
||||
if (message.event === EVENT_MESSAGE) {
|
||||
return await handlePushMessage(data);
|
||||
} else if (message.event === EVENT_MESSAGE_DELETE) {
|
||||
return await handlePushMessageDelete(data);
|
||||
} else if (message.event === EVENT_MESSAGE_CLEAR) {
|
||||
return await handlePushMessageClear(data);
|
||||
}
|
||||
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
|
||||
return await handlePushSubscriptionExpiring(data);
|
||||
}
|
||||
|
||||
return await handlePushUnknown(data);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,10 +197,8 @@ const handleClick = async (event) => {
|
||||
const t = await initI18n();
|
||||
|
||||
const clients = await self.clients.matchAll({ type: "window" });
|
||||
|
||||
const rootUrl = new URL(self.location.origin);
|
||||
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
||||
// perhaps open on another topic
|
||||
const fallbackClient = clients[0];
|
||||
|
||||
if (!event.notification.data?.message) {
|
||||
@@ -232,6 +314,7 @@ precacheAndRoute(
|
||||
|
||||
// Claim all open windows
|
||||
clientsClaim();
|
||||
|
||||
// Delete any cached old dist files from previous service worker versions
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { fetchOrThrow } from "./errors";
|
||||
import { withBearerAuth } from "./utils";
|
||||
import session from "./Session";
|
||||
|
||||
const usersUrl = (baseUrl) => `${baseUrl}/v1/users`;
|
||||
const usersAccessUrl = (baseUrl) => `${baseUrl}/v1/users/access`;
|
||||
|
||||
class AdminApi {
|
||||
async getUsers() {
|
||||
const url = usersUrl(config.base_url);
|
||||
console.log(`[AdminApi] Fetching users ${url}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async addUser(username, password, tier) {
|
||||
const url = usersUrl(config.base_url);
|
||||
const body = { username, password };
|
||||
if (tier) {
|
||||
body.tier = tier;
|
||||
}
|
||||
console.log(`[AdminApi] Adding user ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(username, password, tier) {
|
||||
const url = usersUrl(config.base_url);
|
||||
const body = { username };
|
||||
if (password) {
|
||||
body.password = password;
|
||||
}
|
||||
if (tier) {
|
||||
body.tier = tier;
|
||||
}
|
||||
console.log(`[AdminApi] Updating user ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(username) {
|
||||
const url = usersUrl(config.base_url);
|
||||
console.log(`[AdminApi] Deleting user ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
async allowAccess(username, topic, permission) {
|
||||
const url = usersAccessUrl(config.base_url);
|
||||
console.log(`[AdminApi] Allowing access ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({ username, topic, permission }),
|
||||
});
|
||||
}
|
||||
|
||||
async resetAccess(username, topic) {
|
||||
const url = usersAccessUrl(config.base_url);
|
||||
console.log(`[AdminApi] Resetting access ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({ username, topic }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const adminApi = new AdminApi();
|
||||
export default adminApi;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
15
web/src/app/events.js
Normal 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;
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
FormControl,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Container,
|
||||
Card,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
TextField,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
DialogContentText,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import routes from "./routes";
|
||||
import { AccountContext } from "./App";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import session from "../app/Session";
|
||||
import adminApi from "../app/AdminApi";
|
||||
import { Role } from "../app/AccountApi";
|
||||
|
||||
const Admin = () => {
|
||||
const { account } = useContext(AccountContext);
|
||||
|
||||
// Redirect non-admins away
|
||||
if (!session.exists() || (account && account.role !== Role.ADMIN)) {
|
||||
window.location.href = routes.app;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for account to load
|
||||
if (!account) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ marginTop: 3, marginBottom: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<Users />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Users = () => {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [addDialogKey, setAddDialogKey] = useState(0);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminApi.getUsers();
|
||||
setUsers(data);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error loading users`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const handleAddClick = () => {
|
||||
setAddDialogKey((prev) => prev + 1);
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setAddDialogOpen(false);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ padding: 1 }} aria-label={t("admin_users_title")}>
|
||||
<CardContent sx={{ paddingBottom: 1 }}>
|
||||
<Typography variant="h5" sx={{ marginBottom: 2 }}>
|
||||
{t("admin_users_title")}
|
||||
</Typography>
|
||||
<Paragraph>{t("admin_users_description")}</Paragraph>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{loading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!loading && users && (
|
||||
<div style={{ width: "100%", overflowX: "auto" }}>
|
||||
<UsersTable users={users} onUserChanged={loadUsers} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button onClick={handleAddClick} startIcon={<AddIcon />}>
|
||||
{t("admin_users_add_button")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
<AddUserDialog key={`addUserDialog${addDialogKey}`} open={addDialogOpen} onClose={handleDialogClose} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersTable = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [editDialogKey, setEditDialogKey] = useState(0);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [accessDialogKey, setAccessDialogKey] = useState(0);
|
||||
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
|
||||
const [deleteAccessDialogOpen, setDeleteAccessDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [selectedGrant, setSelectedGrant] = useState(null);
|
||||
|
||||
const { users } = props;
|
||||
|
||||
const handleEditClick = (user) => {
|
||||
setEditDialogKey((prev) => prev + 1);
|
||||
setSelectedUser(user);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAddAccessClick = (user) => {
|
||||
setAccessDialogKey((prev) => prev + 1);
|
||||
setSelectedUser(user);
|
||||
setAccessDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteAccessClick = (user, grant) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedGrant(grant);
|
||||
setDeleteAccessDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setEditDialogOpen(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setAccessDialogOpen(false);
|
||||
setDeleteAccessDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
setSelectedGrant(null);
|
||||
props.onUserChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table size="small" aria-label={t("admin_users_title")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ paddingLeft: 0 }}>{t("admin_users_table_username_header")}</TableCell>
|
||||
<TableCell>{t("admin_users_table_role_header")}</TableCell>
|
||||
<TableCell>{t("admin_users_table_tier_header")}</TableCell>
|
||||
<TableCell>{t("admin_users_table_grants_header")}</TableCell>
|
||||
<TableCell align="right">{t("admin_users_table_actions_header")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.username} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell component="th" scope="row" sx={{ paddingLeft: 0 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<span>{user.username}</span>
|
||||
{user.provisioned && (
|
||||
<Tooltip title={t("admin_users_provisioned_tooltip")}>
|
||||
<LockIcon fontSize="small" color="disabled" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RoleChip role={user.role} />
|
||||
</TableCell>
|
||||
<TableCell>{user.tier || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{user.grants && user.grants.length > 0 ? (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{user.grants.map((grant, idx) => {
|
||||
const canDelete = user.role !== "admin" && !grant.provisioned;
|
||||
const tooltipText = grant.provisioned
|
||||
? t("admin_users_table_grant_provisioned_tooltip", { permission: grant.permission })
|
||||
: t("admin_users_table_grant_tooltip", { permission: grant.permission });
|
||||
return (
|
||||
<Tooltip key={idx} title={tooltipText}>
|
||||
<Chip
|
||||
label={grant.topic}
|
||||
size="small"
|
||||
variant={grant.provisioned ? "filled" : "outlined"}
|
||||
color={grant.provisioned ? "default" : "default"}
|
||||
icon={grant.provisioned ? <LockIcon fontSize="small" /> : undefined}
|
||||
onDelete={canDelete ? () => handleDeleteAccessClick(user, grant) : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{user.role !== "admin" && !user.provisioned ? (
|
||||
<>
|
||||
<Tooltip title={t("admin_users_table_add_access_tooltip")}>
|
||||
<IconButton onClick={() => handleAddAccessClick(user)} size="small">
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("admin_users_table_edit_tooltip")}>
|
||||
<IconButton onClick={() => handleEditClick(user)} size="small">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("admin_users_table_delete_tooltip")}>
|
||||
<IconButton onClick={() => handleDeleteClick(user)} size="small">
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : user.role !== "admin" && user.provisioned ? (
|
||||
<>
|
||||
<Tooltip title={t("admin_users_table_add_access_tooltip")}>
|
||||
<IconButton onClick={() => handleAddAccessClick(user)} size="small">
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("admin_users_provisioned_cannot_edit")}>
|
||||
<span>
|
||||
<IconButton disabled size="small">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled size="small">
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip title={t("admin_users_table_admin_no_actions")}>
|
||||
<span>
|
||||
<IconButton disabled size="small">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EditUserDialog key={`editUserDialog${editDialogKey}`} open={editDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||
<DeleteUserDialog open={deleteDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||
<AddAccessDialog key={`addAccessDialog${accessDialogKey}`} open={accessDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||
<DeleteAccessDialog open={deleteAccessDialogOpen} user={selectedUser} grant={selectedGrant} onClose={handleDialogClose} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleChip = ({ role }) => {
|
||||
const { t } = useTranslation();
|
||||
if (role === "admin") {
|
||||
return <Chip label={t("admin_users_role_admin")} size="small" color="primary" />;
|
||||
}
|
||||
return <Chip label={t("admin_users_role_user")} size="small" variant="outlined" />;
|
||||
};
|
||||
|
||||
const AddUserDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [tier, setTier] = useState("");
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await adminApi.addUser(username, password, tier || undefined);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error adding user`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("admin_users_add_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="username"
|
||||
label={t("admin_users_add_dialog_username_label")}
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(ev) => setUsername(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="password"
|
||||
label={t("admin_users_add_dialog_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="tier"
|
||||
label={t("admin_users_add_dialog_tier_label")}
|
||||
type="text"
|
||||
value={tier}
|
||||
onChange={(ev) => setTier(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
helperText={t("admin_users_add_dialog_tier_helper")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!username || !password}>
|
||||
{t("common_add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const EditUserDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [tier, setTier] = useState(props.user?.tier || "");
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await adminApi.updateUser(props.user.username, password || undefined, tier || undefined);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error updating user`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("admin_users_edit_dialog_title", { username: props.user.username })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="password"
|
||||
label={t("admin_users_edit_dialog_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
helperText={t("admin_users_edit_dialog_password_helper")}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="tier"
|
||||
label={t("admin_users_edit_dialog_tier_label")}
|
||||
type="text"
|
||||
value={tier}
|
||||
onChange={(ev) => setTier(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
helperText={t("admin_users_edit_dialog_tier_helper")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!password && !tier}>
|
||||
{t("common_save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteUserDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await adminApi.deleteUser(props.user.username);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error deleting user`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose}>
|
||||
<DialogTitle>{t("admin_users_delete_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("admin_users_delete_dialog_description", { username: props.user.username })}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} color="error">
|
||||
{t("admin_users_delete_dialog_button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const AddAccessDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [permission, setPermission] = useState("read-write");
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await adminApi.allowAccess(props.user.username, topic, permission);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error adding access`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("admin_access_add_dialog_title", { username: props.user.username })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="topic"
|
||||
label={t("admin_access_add_dialog_topic_label")}
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(ev) => setTopic(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
autoFocus
|
||||
helperText={t("admin_access_add_dialog_topic_helper")}
|
||||
/>
|
||||
<FormControl fullWidth variant="standard" sx={{ mt: 2 }}>
|
||||
<Select
|
||||
value={permission}
|
||||
onChange={(ev) => setPermission(ev.target.value)}
|
||||
label={t("admin_access_add_dialog_permission_label")}
|
||||
>
|
||||
<MenuItem value="read-write">{t("admin_access_permission_read_write")}</MenuItem>
|
||||
<MenuItem value="read-only">{t("admin_access_permission_read_only")}</MenuItem>
|
||||
<MenuItem value="write-only">{t("admin_access_permission_write_only")}</MenuItem>
|
||||
<MenuItem value="deny-all">{t("admin_access_permission_deny_all")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!topic}>
|
||||
{t("common_add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteAccessDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await adminApi.resetAccess(props.user.username, props.grant.topic);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Admin] Error removing access`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.user || !props.grant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose}>
|
||||
<DialogTitle>{t("admin_access_delete_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t("admin_access_delete_dialog_description", { username: props.user.username, topic: props.grant.topic })}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} color="error">
|
||||
{t("admin_access_delete_dialog_button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
|
||||
@@ -20,7 +20,6 @@ import Messaging from "./Messaging";
|
||||
import Login from "./Login";
|
||||
import Signup from "./Signup";
|
||||
import Account from "./Account";
|
||||
import Admin from "./Admin";
|
||||
import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs, { THEME } from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
@@ -81,7 +80,6 @@ const App = () => {
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
<Route path={routes.admin} element={<Admin />} />
|
||||
<Route path={routes.settings} element={<Preferences />} />
|
||||
<Route path={routes.subscription} element={<SingleSubscription />} />
|
||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useContext, useState } from "react";
|
||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||
@@ -165,14 +164,6 @@ const NavList = (props) => {
|
||||
<ListItemText primary={t("nav_button_account")} />
|
||||
</ListItemButton>
|
||||
)}
|
||||
{session.exists() && isAdmin && (
|
||||
<ListItemButton onClick={() => navigate(routes.admin)} selected={location.pathname === routes.admin}>
|
||||
<ListItemIcon>
|
||||
<AdminPanelSettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("nav_button_admin")} />
|
||||
</ListItemButton>
|
||||
)}
|
||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
|
||||
@@ -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"}`);
|
||||
|
||||
@@ -6,7 +6,6 @@ const routes = {
|
||||
signup: "/signup",
|
||||
app: config.app_root,
|
||||
account: "/account",
|
||||
admin: "/admin",
|
||||
settings: "/settings",
|
||||
subscription: "/:topic",
|
||||
subscriptionExternal: "/:baseUrl/:topic",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user