Compare commits
2 Commits
303-update
...
admin-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a940ad289 | ||
|
|
75b2ca7dec |
@@ -6,7 +6,5 @@ As of today, I only support the latest version of ntfy. Please make sure you sta
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).
|
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`).
|
||||||
You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
|
||||||
(my username is `binwiederhier`).
|
|
||||||
|
|||||||
@@ -88,11 +88,6 @@ func WithFilename(filename string) PublishOption {
|
|||||||
return WithHeader("X-Filename", filename)
|
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
|
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||||
func WithEmail(email string) PublishOption {
|
func WithEmail(email string) PublishOption {
|
||||||
return WithHeader("X-Email", email)
|
return WithHeader("X-Email", email)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ var flagsPublish = append(
|
|||||||
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
&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: "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: "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: "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: "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"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
@@ -71,7 +70,6 @@ Examples:
|
|||||||
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
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 --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 --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
|
echo 'message' | ntfy publish mytopic # Send message from stdin
|
||||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
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
|
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
||||||
@@ -103,7 +101,6 @@ func execPublish(c *cli.Context) error {
|
|||||||
markdown := c.Bool("markdown")
|
markdown := c.Bool("markdown")
|
||||||
template := c.String("template")
|
template := c.String("template")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
sequenceID := c.String("sequence-id")
|
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
user := c.String("user")
|
user := c.String("user")
|
||||||
@@ -157,9 +154,6 @@ func execPublish(c *cli.Context) error {
|
|||||||
if filename != "" {
|
if filename != "" {
|
||||||
options = append(options, client.WithFilename(filename))
|
options = append(options, client.WithFilename(filename))
|
||||||
}
|
}
|
||||||
if sequenceID != "" {
|
|
||||||
options = append(options, client.WithSequenceID(sequenceID))
|
|
||||||
}
|
|
||||||
if email != "" {
|
if email != "" {
|
||||||
options = append(options, client.WithEmail(email))
|
options = append(options, client.WithEmail(email))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ or the root domain:
|
|||||||
=== "caddy"
|
=== "caddy"
|
||||||
```
|
```
|
||||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||||
# via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
# via Discord/Matrix or in a GitHub issue.
|
||||||
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
|
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
|
||||||
|
|
||||||
ntfy.sh, http://nfty.sh {
|
ntfy.sh, http://nfty.sh {
|
||||||
@@ -1034,7 +1034,7 @@ or the root domain:
|
|||||||
``` kdl
|
``` kdl
|
||||||
// /etc/ferron.kdl
|
// /etc/ferron.kdl
|
||||||
// Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
// Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||||
// via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
// via Discord/Matrix or in a GitHub issue.
|
||||||
// Note: Ferron automatically handles both HTTP and WebSockets with proxy
|
// Note: Ferron automatically handles both HTTP and WebSockets with proxy
|
||||||
|
|
||||||
ntfy.sh {
|
ntfy.sh {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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.** 😎
|
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
|
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 reach out via one of the channels listed on the [contact page](contact.md).
|
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||||
|
|
||||||
## ntfy server
|
## ntfy server
|
||||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
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. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||||
1. Find the Asset/ folder in the project navigator
|
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
|
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 labeled "GoogleService-Info.plist"
|
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||||
|
|
||||||
After that, you should be all set!
|
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.
|
appreciated.
|
||||||
|
|
||||||
## Can I email you? Can I DM you on Discord/Matrix?
|
## Can I email you? Can I DM you on Discord/Matrix?
|
||||||
For community support, please use the public channels listed on the [contact page](contact.md). I generally
|
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
|
||||||
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
|
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
|
||||||
plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
|
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
|
||||||
opportunities (see [other inquiries](contact.md#other-inquiries)).
|
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
|
||||||
|
|
||||||
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||||
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
|
in the above-mentioned 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.
|
may be able to help out. I hope you understand.
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ 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-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.
|
- [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)
|
- [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
|
## Blog + forum posts
|
||||||
|
|
||||||
|
|||||||
198
docs/privacy.md
198
docs/privacy.md
@@ -1,194 +1,12 @@
|
|||||||
# Privacy policy
|
# Privacy policy
|
||||||
|
|
||||||
**Last updated:** January 2, 2026
|
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.**
|
||||||
|
|
||||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
Neither the server nor the app record any personal information, or share any of the messages and topics with
|
||||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
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.
|
||||||
|
|
||||||
## Our commitment to privacy
|
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.
|
||||||
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).
|
|
||||||
|
|||||||
443
docs/publish.md
443
docs/publish.md
@@ -937,445 +937,6 @@ 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');
|
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
|
## Message templating
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
@@ -1858,7 +1419,7 @@ The JSON message format closely mirrors the format of the message you can consum
|
|||||||
all the supported fields:
|
all the supported fields:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
| Field | Required | Type | Example | Description |
|
||||||
|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
|
|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------|
|
||||||
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
|
||||||
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
|
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
|
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
|
||||||
@@ -1873,7 +1434,6 @@ all the supported fields:
|
|||||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||||
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
| `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) |
|
| `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
|
## Action buttons
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
@@ -4371,7 +3931,6 @@ table in their canonical form.
|
|||||||
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
|
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||||
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
||||||
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
|
| `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-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
|
||||||
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
|
| `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) |
|
| `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`
|
When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`
|
||||||
function along with `sortAlpha` to get a unique, sorted list of keys.
|
function along with `sortAlpha` to get a unqiue, sorted list of keys.
|
||||||
|
|
||||||
```
|
```
|
||||||
keys $myDict $myOtherDict | uniq | sortAlpha
|
keys $myDict $myOtherDict | uniq | sortAlpha
|
||||||
|
|||||||
@@ -5,40 +5,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
## Current stable releases
|
## Current stable releases
|
||||||
|
|
||||||
| Component | Version | Release date |
|
| Component | Version | Release date |
|
||||||
|------------------|---------|--------------|
|
|------------------------------------------|---------|--------------|
|
||||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
||||||
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
|
| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 |
|
||||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||||
|
|
||||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
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
|
## ntfy Android app v1.20.0
|
||||||
Released December 28, 2025
|
Released December 28, 2025
|
||||||
|
|
||||||
@@ -1599,32 +1572,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.16.x (UNRELEASED)
|
### ntfy Android app v1.21.x
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
* 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))
|
||||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
* 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))
|
||||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
* 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))
|
||||||
for the initial implementation)
|
* 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)
|
||||||
### ntfy Android app v1.22.x (UNRELEASED)
|
* 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))
|
||||||
|
|
||||||
**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:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
* 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))
|
||||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
* 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 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
|
|
||||||
|
|||||||
2
docs/static/css/extra.css
vendored
2
docs/static/css/extra.css
vendored
@@ -93,7 +93,7 @@ figure video {
|
|||||||
|
|
||||||
.screenshots img {
|
.screenshots img {
|
||||||
max-height: 230px;
|
max-height: 230px;
|
||||||
max-width: 350px;
|
max-width: 300px;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
filter: drop-shadow(2px 2px 2px #ddd);
|
filter: drop-shadow(2px 2px 2px #ddd);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
@@ -325,13 +325,12 @@ format of the message. It's very straight forward:
|
|||||||
**Message**:
|
**Message**:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
| Field | Required | Type | Example | Description |
|
||||||
|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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 |
|
| `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>` |
|
| `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 |
|
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||||
|
|||||||
@@ -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.
|
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.
|
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 F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
## Share to topic
|
## Share to topic
|
||||||
_Supported on:_ :material-android:
|
_Supported on:_ :material-android:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
|
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 reach out via one of the channels listed on the [contact page](contact.md).
|
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)
|
||||||
We're happy to help.
|
and ask there. We're happy to help.
|
||||||
|
|
||||||
## ntfy server
|
## 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
|
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing
|
||||||
|
|||||||
30
go.mod
30
go.mod
@@ -6,22 +6,22 @@ toolchain go1.24.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||||
cloud.google.com/go/storage v1.59.0 // indirect
|
cloud.google.com/go/storage v1.58.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/emersion/go-smtp v0.18.0
|
github.com/emersion/go-smtp v0.18.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12
|
github.com/gabriel-vasile/mimetype v1.4.12
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.33
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/olebedev/when v1.1.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/term v0.39.0
|
golang.org/x/term v0.38.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/api v0.259.0
|
google.golang.org/api v0.258.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ require (
|
|||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
golang.org/x/text v0.33.0
|
golang.org/x/text v0.32.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -45,7 +45,7 @@ require (
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.3 // indirect
|
cloud.google.com/go/iam v1.5.3 // indirect
|
||||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.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/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.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/sdk/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 // indirect
|
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||||
google.golang.org/grpc v1.78.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
60
go.sum
60
go.sum
@@ -14,12 +14,12 @@ 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/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 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
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/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||||
cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8=
|
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||||
cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
firebase.google.com/go/v4 v4.18.0 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
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/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
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=
|
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_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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
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/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
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/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
|
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||||
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
|
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
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/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4=
|
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
|
||||||
google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
|
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
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.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -99,8 +99,6 @@ nav:
|
|||||||
- "Known issues": known-issues.md
|
- "Known issues": known-issues.md
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.md
|
||||||
- "Development": develop.md
|
- "Development": develop.md
|
||||||
- "Contributing": contributing.md
|
|
||||||
- "Privacy policy": privacy.md
|
- "Privacy policy": privacy.md
|
||||||
- "Contact": contact.md
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||||
@@ -126,7 +125,6 @@ var (
|
|||||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
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}
|
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}
|
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}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ const (
|
|||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mid TEXT NOT NULL,
|
mid TEXT NOT NULL,
|
||||||
sequence_id TEXT NOT NULL,
|
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
event TEXT NOT NULL,
|
|
||||||
expires INT NOT NULL,
|
expires INT NOT NULL,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -54,7 +52,6 @@ const (
|
|||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
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_time ON messages (time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
@@ -69,50 +66,50 @@ const (
|
|||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
selectMessagesByIDQuery = `
|
selectMessagesByIDQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE mid = ?
|
WHERE mid = ?
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesLatestQuery = `
|
selectMessagesLatestQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND published = 1
|
WHERE topic = ? AND published = 1
|
||||||
ORDER BY time DESC, id DESC
|
ORDER BY time DESC, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
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
|
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
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
@@ -134,7 +131,7 @@ const (
|
|||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 14
|
currentSchemaVersion = 13
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -263,13 +260,6 @@ const (
|
|||||||
migrate12To13AlterMessagesTableQuery = `
|
migrate12To13AlterMessagesTableQuery = `
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
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 (
|
var (
|
||||||
@@ -287,7 +277,6 @@ var (
|
|||||||
10: migrateFrom10,
|
10: migrateFrom10,
|
||||||
11: migrateFrom11,
|
11: migrateFrom11,
|
||||||
12: migrateFrom12,
|
12: migrateFrom12,
|
||||||
13: migrateFrom13,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,7 +369,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
for _, m := range ms {
|
for _, m := range ms {
|
||||||
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
|
if m.Event != messageEvent {
|
||||||
return errUnexpectedMessageType
|
return errUnexpectedMessageType
|
||||||
}
|
}
|
||||||
published := m.Time <= time.Now().Unix()
|
published := m.Time <= time.Now().Unix()
|
||||||
@@ -408,9 +397,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
}
|
}
|
||||||
_, err := stmt.Exec(
|
_, err := stmt.Exec(
|
||||||
m.ID,
|
m.ID,
|
||||||
m.SequenceID,
|
|
||||||
m.Time,
|
m.Time,
|
||||||
m.Event,
|
|
||||||
m.Expires,
|
m.Expires,
|
||||||
m.Topic,
|
m.Topic,
|
||||||
m.Message,
|
m.Message,
|
||||||
@@ -719,12 +706,10 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
func readMessage(rows *sql.Rows) (*message, error) {
|
func readMessage(rows *sql.Rows) (*message, error) {
|
||||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
&sequenceID,
|
|
||||||
×tamp,
|
×tamp,
|
||||||
&event,
|
|
||||||
&expires,
|
&expires,
|
||||||
&topic,
|
&topic,
|
||||||
&msg,
|
&msg,
|
||||||
@@ -773,10 +758,9 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
}
|
}
|
||||||
return &message{
|
return &message{
|
||||||
ID: id,
|
ID: id,
|
||||||
SequenceID: sequenceID,
|
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
Event: event,
|
Event: messageEvent,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -1046,19 +1030,3 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
return tx.Commit()
|
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,7 +319,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
m.SequenceID = "m1"
|
|
||||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "flower.jpg",
|
Name: "flower.jpg",
|
||||||
@@ -333,7 +332,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("mytopic", "sending you a car")
|
m = newDefaultMessage("mytopic", "sending you a car")
|
||||||
m.ID = "m2"
|
m.ID = "m2"
|
||||||
m.SequenceID = "m2"
|
|
||||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "car.jpg",
|
Name: "car.jpg",
|
||||||
@@ -347,7 +345,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("another-topic", "sending you another car")
|
m = newDefaultMessage("another-topic", "sending you another car")
|
||||||
m.ID = "m3"
|
m.ID = "m3"
|
||||||
m.SequenceID = "m3"
|
|
||||||
m.User = "u_BAsbaAa"
|
m.User = "u_BAsbaAa"
|
||||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
@@ -403,13 +400,11 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
|
|||||||
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
m.SequenceID = "m1"
|
|
||||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||||
require.Nil(t, c.AddMessage(m))
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
m = newDefaultMessage("mytopic", "message with attachment")
|
m = newDefaultMessage("mytopic", "message with attachment")
|
||||||
m.ID = "m2"
|
m.ID = "m2"
|
||||||
m.SequenceID = "m2"
|
|
||||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "car.jpg",
|
Name: "car.jpg",
|
||||||
@@ -422,7 +417,6 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
|||||||
|
|
||||||
m = newDefaultMessage("mytopic", "message with external attachment")
|
m = newDefaultMessage("mytopic", "message with external attachment")
|
||||||
m.ID = "m3"
|
m.ID = "m3"
|
||||||
m.SequenceID = "m3"
|
|
||||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "car.jpg",
|
Name: "car.jpg",
|
||||||
@@ -434,7 +428,6 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
|||||||
|
|
||||||
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
||||||
m.ID = "m4"
|
m.ID = "m4"
|
||||||
m.SequenceID = "m4"
|
|
||||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "expired-car.jpg",
|
Name: "expired-car.jpg",
|
||||||
|
|||||||
107
server/server.go
107
server/server.go
@@ -80,12 +80,11 @@ var (
|
|||||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
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$`)
|
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)$`)
|
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"
|
webConfigPath = "/config.js"
|
||||||
webManifestPath = "/manifest.webmanifest"
|
webManifestPath = "/manifest.webmanifest"
|
||||||
|
webRootHTMLPath = "/app.html"
|
||||||
|
webServiceWorkerPath = "/sw.js"
|
||||||
accountPath = "/account"
|
accountPath = "/account"
|
||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
@@ -109,7 +108,7 @@ var (
|
|||||||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||||
staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||||
urlRegex = regexp.MustCompile(`^https?://`)
|
urlRegex = regexp.MustCompile(`^https?://`)
|
||||||
@@ -138,7 +137,7 @@ var (
|
|||||||
const (
|
const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||||
emptyMessageBody = "triggered" // Used when a message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
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
|
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
|
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||||
@@ -532,7 +531,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.handleMatrixDiscovery(w)
|
return s.handleMatrixDiscovery(w)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||||
return s.handleMetrics(w, r, v)
|
return s.handleMetrics(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
|
||||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||||
@@ -544,12 +543,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
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) || updatePathRegex.MatchString(r.URL.Path)) {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
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) {
|
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||||
@@ -877,7 +872,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
minc(metricMessagesPublishedSuccess)
|
minc(metricMessagesPublishedSuccess)
|
||||||
return s.writeJSON(w, m.forJSON())
|
return s.writeJSON(w, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
@@ -905,58 +900,6 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
|
|||||||
return writeMatrixSuccess(w)
|
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) {
|
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||||
@@ -1014,24 +957,6 @@ 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) {
|
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")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = readParam(r, "x-title", "title", "t")
|
||||||
@@ -1346,7 +1271,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 {
|
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
encoder := func(msg *message) (string, error) {
|
encoder := func(msg *message) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
@@ -1357,10 +1282,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 {
|
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
encoder := func(msg *message) (string, error) {
|
encoder := func(msg *message) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
if msg.Event != messageEvent {
|
||||||
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
|
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
|
return fmt.Sprintf("data: %s\n", buf.String()), nil
|
||||||
@@ -1770,15 +1695,6 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
|||||||
return topics, parts[1], nil
|
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.
|
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
|
||||||
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -2033,9 +1949,6 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if m.Firebase != "" {
|
if m.Firebase != "" {
|
||||||
r.Header.Set("X-Firebase", 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)
|
return next(w, r, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
userGrants[i] = &apiUserGrantResponse{
|
userGrants[i] = &apiUserGrantResponse{
|
||||||
Topic: g.TopicPattern,
|
Topic: g.TopicPattern,
|
||||||
Permission: g.Permission.String(),
|
Permission: g.Permission.String(),
|
||||||
|
Provisioned: g.Provisioned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usersResponse[i] = &apiUserResponse{
|
usersResponse[i] = &apiUserResponse{
|
||||||
@@ -33,6 +34,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
Role: string(u.Role),
|
Role: string(u.Role),
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
Grants: userGrants,
|
Grants: userGrants,
|
||||||
|
Provisioned: u.Provisioned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, usersResponse)
|
return s.writeJSON(w, usersResponse)
|
||||||
|
|||||||
@@ -143,15 +143,6 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
|||||||
"poll_id": m.PollID,
|
"poll_id": m.PollID,
|
||||||
}
|
}
|
||||||
apnsConfig = createAPNSAlertConfig(m, data)
|
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:
|
case messageEvent:
|
||||||
if auther != nil {
|
if auther != nil {
|
||||||
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
||||||
@@ -170,7 +161,6 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
|||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": m.Event,
|
"event": m.Event,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
"sequence_id": m.SequenceID,
|
|
||||||
"priority": fmt.Sprintf("%d", m.Priority),
|
"priority": fmt.Sprintf("%d", m.Priority),
|
||||||
"tags": strings.Join(m.Tags, ","),
|
"tags": strings.Join(m.Tags, ","),
|
||||||
"click": m.Click,
|
"click": m.Click,
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"sequence_id": "",
|
|
||||||
"priority": "4",
|
"priority": "4",
|
||||||
"tags": strings.Join(m.Tags, ","),
|
"tags": strings.Join(m.Tags, ","),
|
||||||
"click": "https://google.com",
|
"click": "https://google.com",
|
||||||
@@ -200,7 +199,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"sequence_id": "",
|
|
||||||
"priority": "4",
|
"priority": "4",
|
||||||
"tags": strings.Join(m.Tags, ","),
|
"tags": strings.Join(m.Tags, ","),
|
||||||
"click": "https://google.com",
|
"click": "https://google.com",
|
||||||
@@ -234,7 +232,6 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
|||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": "poll_request",
|
"event": "poll_request",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"sequence_id": "",
|
|
||||||
"message": "New message",
|
"message": "New message",
|
||||||
"title": "",
|
"title": "",
|
||||||
"tags": "",
|
"tags": "",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -22,9 +24,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -678,86 +678,6 @@ func TestServer_PublishInvalidTopic(t *testing.T) {
|
|||||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
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) {
|
func TestServer_PollWithQueryFilters(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -3289,212 +3209,6 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
|
|||||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
|
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 {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
|
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.forJSON()))
|
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
|
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const (
|
|||||||
openEvent = "open"
|
openEvent = "open"
|
||||||
keepaliveEvent = "keepalive"
|
keepaliveEvent = "keepalive"
|
||||||
messageEvent = "message"
|
messageEvent = "message"
|
||||||
messageDeleteEvent = "message_delete"
|
|
||||||
messageClearEvent = "message_clear"
|
|
||||||
pollRequestEvent = "poll_request"
|
pollRequestEvent = "poll_request"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +25,6 @@ const (
|
|||||||
// message represents a message published to a topic
|
// message represents a message published to a topic
|
||||||
type message struct {
|
type message struct {
|
||||||
ID string `json:"id"` // Random message ID
|
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
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||||
Event string `json:"event"` // One of the above
|
Event string `json:"event"` // One of the above
|
||||||
@@ -42,7 +39,7 @@ type message struct {
|
|||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
PollID string `json:"poll_id,omitempty"`
|
PollID string `json:"poll_id,omitempty"`
|
||||||
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
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
|
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||||
}
|
}
|
||||||
@@ -51,7 +48,6 @@ func (m *message) Context() log.Context {
|
|||||||
fields := map[string]any{
|
fields := map[string]any{
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
"message_id": m.ID,
|
"message_id": m.ID,
|
||||||
"message_sequence_id": m.SequenceID,
|
|
||||||
"message_time": m.Time,
|
"message_time": m.Time,
|
||||||
"message_event": m.Event,
|
"message_event": m.Event,
|
||||||
"message_body_size": len(m.Message),
|
"message_body_size": len(m.Message),
|
||||||
@@ -65,17 +61,6 @@ func (m *message) Context() log.Context {
|
|||||||
return fields
|
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 {
|
type attachment struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
@@ -107,7 +92,6 @@ func newAction() *action {
|
|||||||
// publishMessage is used as input when publishing as JSON
|
// publishMessage is used as input when publishing as JSON
|
||||||
type publishMessage struct {
|
type publishMessage struct {
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
SequenceID string `json:"sequence_id"`
|
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
@@ -161,13 +145,6 @@ func newPollRequestMessage(topic, pollID string) *message {
|
|||||||
return m
|
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 {
|
func validMessageID(s string) bool {
|
||||||
return util.ValidRandomString(s, messageIDLength)
|
return util.ValidRandomString(s, messageIDLength)
|
||||||
}
|
}
|
||||||
@@ -246,7 +223,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *queryFilter) Pass(msg *message) bool {
|
func (q *queryFilter) Pass(msg *message) bool {
|
||||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
if msg.Event != messageEvent {
|
||||||
return true // filters only apply to messages
|
return true // filters only apply to messages
|
||||||
} else if q.ID != "" && msg.ID != q.ID {
|
} else if q.ID != "" && msg.ID != q.ID {
|
||||||
return false
|
return false
|
||||||
@@ -335,11 +312,13 @@ type apiUserResponse struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Tier string `json:"tier,omitempty"`
|
Tier string `json:"tier,omitempty"`
|
||||||
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiUserGrantResponse struct {
|
type apiUserGrantResponse struct {
|
||||||
Topic string `json:"topic"` // This may be a pattern
|
Topic string `json:"topic"` // This may be a pattern
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiUserDeleteRequest struct {
|
type apiUserDeleteRequest struct {
|
||||||
|
|||||||
813
web/package-lock.json
generated
813
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -405,5 +405,48 @@
|
|||||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||||
"web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications",
|
"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_title": "Unknown notification received from server",
|
||||||
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,7 +406,5 @@
|
|||||||
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
|
"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_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",
|
"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,7 +50,7 @@
|
|||||||
"publish_dialog_progress_uploading": "Mengunggah …",
|
"publish_dialog_progress_uploading": "Mengunggah …",
|
||||||
"notifications_more_details": "Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.",
|
"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_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
"publish_dialog_message_published": "Notifikasi dipublikasi",
|
"publish_dialog_message_published": "Notifikasi terpublikasi",
|
||||||
"notifications_loading": "Memuat notifikasi …",
|
"notifications_loading": "Memuat notifikasi …",
|
||||||
"publish_dialog_base_url_label": "URL Layanan",
|
"publish_dialog_base_url_label": "URL Layanan",
|
||||||
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
|
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
"publish_dialog_priority_high": "Prioritas tinggi",
|
"publish_dialog_priority_high": "Prioritas tinggi",
|
||||||
"publish_dialog_priority_max": "Prioritas maksimal",
|
"publish_dialog_priority_max": "Prioritas maksimal",
|
||||||
"publish_dialog_topic_label": "Nama topik",
|
"publish_dialog_topic_label": "Nama topik",
|
||||||
"publish_dialog_message_placeholder": "Tulis pesan di sini",
|
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
|
||||||
"publish_dialog_click_label": "Klik URL",
|
"publish_dialog_click_label": "Klik URL",
|
||||||
"publish_dialog_tags_placeholder": "Daftar label yang dipisah dengan tanda koma, contoh: peringatan, cadangan-srv1",
|
"publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
|
||||||
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
|
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
|
||||||
"publish_dialog_email_label": "Email",
|
"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, mis. andi@contoh.com",
|
||||||
@@ -404,7 +404,5 @@
|
|||||||
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||||
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
"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_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_title_no_topic": "通知を送信",
|
||||||
"publish_dialog_progress_uploading": "アップロード中…",
|
"publish_dialog_progress_uploading": "アップロード中…",
|
||||||
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
|
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
"publish_dialog_message_published": "通知送信済み",
|
"publish_dialog_message_published": "通知を送信しました",
|
||||||
"publish_dialog_title_label": "タイトル",
|
"publish_dialog_title_label": "タイトル",
|
||||||
"publish_dialog_filename_label": "ファイル名",
|
"publish_dialog_filename_label": "ファイル名",
|
||||||
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
||||||
@@ -69,10 +69,10 @@
|
|||||||
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
|
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
|
||||||
"publish_dialog_priority_high": "優先度 高",
|
"publish_dialog_priority_high": "優先度 高",
|
||||||
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
|
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
|
||||||
"publish_dialog_title_placeholder": "通知タイトル、例: ディスクスペース警告",
|
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
|
||||||
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
|
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
|
||||||
"publish_dialog_tags_label": "タグ",
|
"publish_dialog_tags_label": "タグ",
|
||||||
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください、例: warning, srv1-backup",
|
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
|
||||||
"publish_dialog_topic_label": "トピック名",
|
"publish_dialog_topic_label": "トピック名",
|
||||||
"publish_dialog_delay_label": "遅延",
|
"publish_dialog_delay_label": "遅延",
|
||||||
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
|
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
|
||||||
|
|||||||
@@ -50,47 +50,5 @@
|
|||||||
"nav_topics_title": "Претплатени теми",
|
"nav_topics_title": "Претплатени теми",
|
||||||
"nav_button_all_notifications": "Сите нотификации",
|
"nav_button_all_notifications": "Сите нотификации",
|
||||||
"nav_button_publish_message": "Објави нотификација",
|
"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",
|
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
|
||||||
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
|
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
|
||||||
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
|
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
|
||||||
"publish_dialog_title_placeholder": "通知标题,如磁盘空间告警",
|
"publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
|
||||||
"publish_dialog_email_label": "电子邮件",
|
"publish_dialog_email_label": "电子邮件",
|
||||||
"publish_dialog_button_send": "发送",
|
"publish_dialog_button_send": "发送",
|
||||||
"publish_dialog_checkbox_markdown": "格式化为 Markdown",
|
"publish_dialog_checkbox_markdown": "格式化为 Markdown",
|
||||||
@@ -203,17 +203,17 @@
|
|||||||
"error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
|
"error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
|
||||||
"prefs_users_table": "用户表",
|
"prefs_users_table": "用户表",
|
||||||
"prefs_users_edit_button": "编辑用户",
|
"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>。",
|
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
|
||||||
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
|
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
|
||||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}} 或 {{naturalLanguage}} (仅限英语)",
|
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)",
|
||||||
"account_usage_basis_ip_description": "此账户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
|
"account_usage_basis_ip_description": "此帐户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
|
||||||
"account_usage_cannot_create_portal_session": "无法打开计费门户",
|
"account_usage_cannot_create_portal_session": "无法打开计费门户",
|
||||||
"account_delete_title": "删除账户",
|
"account_delete_title": "删除帐户",
|
||||||
"account_delete_description": "永久删除您的账户",
|
"account_delete_description": "永久删除您的帐户",
|
||||||
"signup_error_username_taken": "用户名 {{username}} 已被占用",
|
"signup_error_username_taken": "用户名 {{username}} 已被占用",
|
||||||
"signup_error_creation_limit_reached": "已达到账户创建限制",
|
"signup_error_creation_limit_reached": "已达到帐户创建限制",
|
||||||
"login_title": "请登录你的 ntfy 账户",
|
"login_title": "请登录你的 ntfy 帐户",
|
||||||
"action_bar_change_display_name": "更改显示名称",
|
"action_bar_change_display_name": "更改显示名称",
|
||||||
"action_bar_reservation_add": "保留主题",
|
"action_bar_reservation_add": "保留主题",
|
||||||
"action_bar_reservation_delete": "移除保留",
|
"action_bar_reservation_delete": "移除保留",
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
"action_bar_profile_logout": "登出",
|
"action_bar_profile_logout": "登出",
|
||||||
"action_bar_sign_in": "登录",
|
"action_bar_sign_in": "登录",
|
||||||
"action_bar_sign_up": "注册",
|
"action_bar_sign_up": "注册",
|
||||||
"nav_button_account": "账户",
|
"nav_button_account": "帐户",
|
||||||
"nav_upgrade_banner_label": "升级到 ntfy Pro",
|
"nav_upgrade_banner_label": "升级到 ntfy Pro",
|
||||||
"nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件",
|
"nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件",
|
||||||
"alert_not_supported_context_description": "通知仅支持 HTTPS。这是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
"alert_not_supported_context_description": "通知仅支持 HTTPS。这是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
"reserve_dialog_checkbox_label": "保留主题并配置访问",
|
"reserve_dialog_checkbox_label": "保留主题并配置访问",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名称",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名称",
|
||||||
"account_basics_username_description": "嘿,那是你 ❤",
|
"account_basics_username_description": "嘿,那是你 ❤",
|
||||||
"account_basics_password_description": "更改您的账户密码",
|
"account_basics_password_description": "更改您的帐户密码",
|
||||||
"account_basics_password_dialog_title": "更改密码",
|
"account_basics_password_dialog_title": "更改密码",
|
||||||
"account_basics_password_dialog_current_password_label": "当前密码",
|
"account_basics_password_dialog_current_password_label": "当前密码",
|
||||||
"account_basics_password_dialog_new_password_label": "新密码",
|
"account_basics_password_dialog_new_password_label": "新密码",
|
||||||
@@ -244,8 +244,8 @@
|
|||||||
"account_usage_of_limit": "{{limit}} 的",
|
"account_usage_of_limit": "{{limit}} 的",
|
||||||
"account_usage_unlimited": "无限",
|
"account_usage_unlimited": "无限",
|
||||||
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
|
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
|
||||||
"account_basics_tier_title": "账户类型",
|
"account_basics_tier_title": "帐户类型",
|
||||||
"account_basics_tier_description": "您账户的权限级别",
|
"account_basics_tier_description": "您帐户的权限级别",
|
||||||
"account_basics_tier_admin": "管理员",
|
"account_basics_tier_admin": "管理员",
|
||||||
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)",
|
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)",
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(无等级)",
|
"account_basics_tier_admin_suffix_no_tier": "(无等级)",
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
"account_usage_messages_title": "已发布消息",
|
"account_usage_messages_title": "已发布消息",
|
||||||
"account_usage_emails_title": "已发送电子邮件",
|
"account_usage_emails_title": "已发送电子邮件",
|
||||||
"account_usage_reservations_title": "保留主题",
|
"account_usage_reservations_title": "保留主题",
|
||||||
"account_usage_reservations_none": "此账户没有保留主题",
|
"account_usage_reservations_none": "此帐户没有保留主题",
|
||||||
"account_usage_attachment_storage_title": "附件存储",
|
"account_usage_attachment_storage_title": "附件存储",
|
||||||
"account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除",
|
"account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除",
|
||||||
"account_upgrade_dialog_button_pay_now": "立即付款并订阅",
|
"account_upgrade_dialog_button_pay_now": "立即付款并订阅",
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
"account_tokens_delete_dialog_title": "删除访问令牌",
|
"account_tokens_delete_dialog_title": "删除访问令牌",
|
||||||
"account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 <strong>此操作无法撤消</strong>。",
|
"account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 <strong>此操作无法撤消</strong>。",
|
||||||
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
|
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
|
||||||
"prefs_users_description_no_sync": "用户和密码不会同步到您的账户。",
|
"prefs_users_description_no_sync": "用户和密码不会同步到您的帐户。",
|
||||||
"prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户",
|
"prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户",
|
||||||
"prefs_reservations_title": "保留主题",
|
"prefs_reservations_title": "保留主题",
|
||||||
"prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。",
|
"prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。",
|
||||||
@@ -305,13 +305,13 @@
|
|||||||
"reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件",
|
"reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件",
|
||||||
"reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。",
|
"reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。",
|
||||||
"reservation_delete_dialog_submit_button": "删除保留",
|
"reservation_delete_dialog_submit_button": "删除保留",
|
||||||
"account_delete_dialog_description": "这将永久删除您的账户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。",
|
"account_delete_dialog_description": "这将永久删除您的帐户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。",
|
||||||
"account_delete_dialog_label": "密码",
|
"account_delete_dialog_label": "密码",
|
||||||
"account_delete_dialog_button_cancel": "取消",
|
"account_delete_dialog_button_cancel": "取消",
|
||||||
"account_delete_dialog_button_submit": "永久删除账户",
|
"account_delete_dialog_button_submit": "永久删除帐户",
|
||||||
"account_delete_dialog_billing_warning": "删除您的账户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
|
"account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
|
||||||
"account_upgrade_dialog_title": "更改账户等级",
|
"account_upgrade_dialog_title": "更改帐户等级",
|
||||||
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的账户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
|
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
|
||||||
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付费计划之间升级时,差价将被<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_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 1 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
"account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||||
@@ -322,30 +322,30 @@
|
|||||||
"signup_form_confirm_password": "确认密码",
|
"signup_form_confirm_password": "确认密码",
|
||||||
"signup_form_button_submit": "注册",
|
"signup_form_button_submit": "注册",
|
||||||
"signup_form_toggle_password_visibility": "切换密码可见性",
|
"signup_form_toggle_password_visibility": "切换密码可见性",
|
||||||
"signup_title": "创建一个 ntfy 账户",
|
"signup_title": "创建一个 ntfy 帐户",
|
||||||
"signup_form_username": "用户名",
|
"signup_form_username": "用户名",
|
||||||
"signup_form_password": "密码",
|
"signup_form_password": "密码",
|
||||||
"signup_already_have_account": "已有账户?登录!",
|
"signup_already_have_account": "已有帐户?登录!",
|
||||||
"signup_disabled": "注册已禁用",
|
"signup_disabled": "注册已禁用",
|
||||||
"login_form_button_submit": "登录",
|
"login_form_button_submit": "登录",
|
||||||
"login_link_signup": "注册",
|
"login_link_signup": "注册",
|
||||||
"login_disabled": "登录已禁用",
|
"login_disabled": "登录已禁用",
|
||||||
"action_bar_account": "账户",
|
"action_bar_account": "帐户",
|
||||||
"action_bar_reservation_edit": "更改保留",
|
"action_bar_reservation_edit": "更改保留",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "主题已保留",
|
"subscribe_dialog_error_topic_already_reserved": "主题已保留",
|
||||||
"account_basics_title": "账户",
|
"account_basics_title": "帐户",
|
||||||
"account_basics_username_title": "用户名",
|
"account_basics_username_title": "用户名",
|
||||||
"account_basics_username_admin_tooltip": "你是管理员",
|
"account_basics_username_admin_tooltip": "你是管理员",
|
||||||
"account_basics_password_title": "密码",
|
"account_basics_password_title": "密码",
|
||||||
"account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的账户将很快被降级。",
|
"account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的帐户将很快被降级。",
|
||||||
"account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费账户。",
|
"account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费帐户。",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间",
|
||||||
"account_upgrade_dialog_tier_selected_label": "已选",
|
"account_upgrade_dialog_tier_selected_label": "已选",
|
||||||
"account_upgrade_dialog_tier_current_label": "当前",
|
"account_upgrade_dialog_tier_current_label": "当前",
|
||||||
"account_upgrade_dialog_button_cancel": "取消",
|
"account_upgrade_dialog_button_cancel": "取消",
|
||||||
"account_upgrade_dialog_button_redirect_signup": "立即注册",
|
"account_upgrade_dialog_button_redirect_signup": "立即注册",
|
||||||
"account_tokens_title": "访问令牌",
|
"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_token_header": "令牌",
|
||||||
"account_tokens_table_label_header": "标签",
|
"account_tokens_table_label_header": "标签",
|
||||||
"account_tokens_table_last_access_header": "最后访问",
|
"account_tokens_table_last_access_header": "最后访问",
|
||||||
@@ -403,7 +403,5 @@
|
|||||||
"web_push_subscription_expiring_title": "通知将被暂停",
|
"web_push_subscription_expiring_title": "通知将被暂停",
|
||||||
"web_push_subscription_expiring_body": "打开ntfy以继续接收通知",
|
"web_push_subscription_expiring_body": "打开ntfy以继续接收通知",
|
||||||
"web_push_unknown_notification_title": "接收到未知通知",
|
"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,7 +403,5 @@
|
|||||||
"web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知",
|
"web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知",
|
||||||
"web_push_subscription_expiring_title": "通知會被暫停",
|
"web_push_subscription_expiring_title": "通知會被暫停",
|
||||||
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy",
|
"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,16 +3,11 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
|
|||||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||||
import { NetworkFirst } from "workbox-strategies";
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
import { clientsClaim } from "workbox-core";
|
import { clientsClaim } from "workbox-core";
|
||||||
|
|
||||||
import { dbAsync } from "../src/app/db";
|
import { dbAsync } from "../src/app/db";
|
||||||
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
|
|
||||||
|
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
|
||||||
import initI18n from "../src/app/i18n";
|
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:
|
* General docs for service workers and PWAs:
|
||||||
@@ -26,6 +21,25 @@ import {
|
|||||||
|
|
||||||
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
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.
|
* Handle a received web push message and show notification.
|
||||||
*
|
*
|
||||||
@@ -34,35 +48,10 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
|||||||
*/
|
*/
|
||||||
const handlePushMessage = async (data) => {
|
const handlePushMessage = async (data) => {
|
||||||
const { subscription_id: subscriptionId, message } = data;
|
const { subscription_id: subscriptionId, message } = data;
|
||||||
const db = await dbAsync();
|
|
||||||
|
|
||||||
console.log("[ServiceWorker] Message received", data);
|
broadcastChannel.postMessage(message); // To potentially play sound
|
||||||
|
|
||||||
// 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(
|
await self.registration.showNotification(
|
||||||
...toNotificationParams({
|
...toNotificationParams({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@@ -73,70 +62,11 @@ 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.
|
* Handle a received web push subscription expiring.
|
||||||
*/
|
*/
|
||||||
const handlePushSubscriptionExpiring = async (data) => {
|
const handlePushSubscriptionExpiring = async (data) => {
|
||||||
const t = await initI18n();
|
const t = await initI18n();
|
||||||
console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
|
|
||||||
|
|
||||||
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
|
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
|
||||||
body: t("web_push_subscription_expiring_body"),
|
body: t("web_push_subscription_expiring_body"),
|
||||||
@@ -152,7 +82,6 @@ const handlePushSubscriptionExpiring = async (data) => {
|
|||||||
*/
|
*/
|
||||||
const handlePushUnknown = async (data) => {
|
const handlePushUnknown = async (data) => {
|
||||||
const t = await initI18n();
|
const t = await initI18n();
|
||||||
console.log("[ServiceWorker] Unknown event received", data);
|
|
||||||
|
|
||||||
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
|
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
|
||||||
body: t("web_push_unknown_notification_body"),
|
body: t("web_push_unknown_notification_body"),
|
||||||
@@ -167,26 +96,13 @@ const handlePushUnknown = async (data) => {
|
|||||||
* @param {object} data see server/types.go, type webPushPayload
|
* @param {object} data see server/types.go, type webPushPayload
|
||||||
*/
|
*/
|
||||||
const handlePush = async (data) => {
|
const handlePush = async (data) => {
|
||||||
// This logic is (partially) duplicated in
|
if (data.event === "message") {
|
||||||
// - Android: SubscriberService::onNotificationReceived()
|
await handlePushMessage(data);
|
||||||
// - Android: FirebaseService::onMessageReceived()
|
} else if (data.event === "subscription_expiring") {
|
||||||
// - Web app: hooks.js:handleNotification()
|
await handlePushSubscriptionExpiring(data);
|
||||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
} else {
|
||||||
|
await handlePushUnknown(data);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,8 +113,10 @@ const handleClick = async (event) => {
|
|||||||
const t = await initI18n();
|
const t = await initI18n();
|
||||||
|
|
||||||
const clients = await self.clients.matchAll({ type: "window" });
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
|
||||||
const rootUrl = new URL(self.location.origin);
|
const rootUrl = new URL(self.location.origin);
|
||||||
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
||||||
|
// perhaps open on another topic
|
||||||
const fallbackClient = clients[0];
|
const fallbackClient = clients[0];
|
||||||
|
|
||||||
if (!event.notification.data?.message) {
|
if (!event.notification.data?.message) {
|
||||||
@@ -314,7 +232,6 @@ precacheAndRoute(
|
|||||||
|
|
||||||
// Claim all open windows
|
// Claim all open windows
|
||||||
clientsClaim();
|
clientsClaim();
|
||||||
|
|
||||||
// Delete any cached old dist files from previous service worker versions
|
// Delete any cached old dist files from previous service worker versions
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
|||||||
82
web/src/app/AdminApi.js
Normal file
82
web/src/app/AdminApi.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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,6 +1,5 @@
|
|||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
||||||
import { EVENT_OPEN, isNotificationEvent } from "./events";
|
|
||||||
|
|
||||||
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
||||||
|
|
||||||
@@ -49,11 +48,10 @@ class Connection {
|
|||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.event === EVENT_OPEN) {
|
if (data.event === "open") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Accept message, message_delete, and message_clear events
|
const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
|
||||||
const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data;
|
|
||||||
if (!relevantAndValid) {
|
if (!relevantAndValid) {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -31,21 +31,6 @@ 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() {
|
async playSound() {
|
||||||
// Play sound
|
// Play sound
|
||||||
const sound = await prefs.sound();
|
const sound = await prefs.sound();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import api from "./Api";
|
import api from "./Api";
|
||||||
import prefs from "./Prefs";
|
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events";
|
|
||||||
|
|
||||||
const delayMillis = 2000; // 2 seconds
|
const delayMillis = 2000; // 2 seconds
|
||||||
const intervalMillis = 300000; // 5 minutes
|
const intervalMillis = 300000; // 5 minutes
|
||||||
@@ -44,35 +42,12 @@ class Poller {
|
|||||||
|
|
||||||
const since = subscription.last;
|
const since = subscription.last;
|
||||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
||||||
|
if (!notifications || notifications.length === 0) {
|
||||||
// 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] No new notifications found for ${subscription.id}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
||||||
|
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||||
}
|
}
|
||||||
|
|
||||||
pollInBackground(subscription) {
|
pollInBackground(subscription) {
|
||||||
@@ -84,21 +59,6 @@ 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();
|
const poller = new Poller();
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import notifier from "./Notifier";
|
|||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import { topicUrl } from "./utils";
|
import { topicUrl } from "./utils";
|
||||||
import { messageWithSequenceId } from "./notificationUtils";
|
|
||||||
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
|
|
||||||
|
|
||||||
class SubscriptionManager {
|
class SubscriptionManager {
|
||||||
constructor(dbImpl) {
|
constructor(dbImpl) {
|
||||||
@@ -50,17 +48,16 @@ class SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async notify(subscriptionId, notification) {
|
async notify(subscriptionId, notification) {
|
||||||
if (notification.event !== EVENT_MESSAGE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const subscription = await this.get(subscriptionId);
|
const subscription = await this.get(subscriptionId);
|
||||||
if (subscription.mutedUntil > 0) {
|
if (subscription.mutedUntil > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priority = notification.priority ?? 3;
|
const priority = notification.priority ?? 3;
|
||||||
if (priority < (await prefs.minPriority())) {
|
if (priority < (await prefs.minPriority())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await notifier.notify(subscription, notification);
|
await notifier.notify(subscription, notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +157,7 @@ class SubscriptionManager {
|
|||||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||||
|
|
||||||
return this.db.notifications
|
return this.db.notifications
|
||||||
.orderBy("time") // Sort by time
|
.orderBy("time") // Sort by time first
|
||||||
.filter((n) => n.subscriptionId === subscriptionId)
|
.filter((n) => n.subscriptionId === subscriptionId)
|
||||||
.reverse()
|
.reverse()
|
||||||
.toArray();
|
.toArray();
|
||||||
@@ -176,22 +173,17 @@ class SubscriptionManager {
|
|||||||
/** Adds notification, or returns false if it already exists */
|
/** Adds notification, or returns false if it already exists */
|
||||||
async addNotification(subscriptionId, notification) {
|
async addNotification(subscriptionId, notification) {
|
||||||
const exists = await this.db.notifications.get(notification.id);
|
const exists = await this.db.notifications.get(notification.id);
|
||||||
if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
|
if (exists) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Note: Service worker (sw.js) and addNotifications() duplicates this logic,
|
// sw.js duplicates this logic, so if you change it here, change it there too
|
||||||
// so if you change it here, change it there too.
|
|
||||||
|
|
||||||
// Add notification to database
|
|
||||||
await this.db.notifications.add({
|
await this.db.notifications.add({
|
||||||
...messageWithSequenceId(notification),
|
...notification,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
});
|
new: 1,
|
||||||
|
}); // FIXME consider put() for double tab
|
||||||
// FIXME consider put() for double tab
|
|
||||||
// Update subscription last message id (for ?since=... queries)
|
|
||||||
await this.db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
last: notification.id,
|
last: notification.id,
|
||||||
});
|
});
|
||||||
@@ -203,10 +195,7 @@ class SubscriptionManager {
|
|||||||
|
|
||||||
/** Adds/replaces notifications, will not throw if they exist */
|
/** Adds/replaces notifications, will not throw if they exist */
|
||||||
async addNotifications(subscriptionId, notifications) {
|
async addNotifications(subscriptionId, notifications) {
|
||||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({
|
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
||||||
...messageWithSequenceId(notification),
|
|
||||||
subscriptionId,
|
|
||||||
}));
|
|
||||||
const lastNotificationId = notifications.at(-1).id;
|
const lastNotificationId = notifications.at(-1).id;
|
||||||
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
|
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||||
await this.db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
@@ -231,10 +220,6 @@ class SubscriptionManager {
|
|||||||
await this.db.notifications.delete(notificationId);
|
await this.db.notifications.delete(notificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
|
|
||||||
await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteNotifications(subscriptionId) {
|
async deleteNotifications(subscriptionId) {
|
||||||
await this.db.notifications.where({ subscriptionId }).delete();
|
await this.db.notifications.where({ subscriptionId }).delete();
|
||||||
}
|
}
|
||||||
@@ -243,10 +228,6 @@ class SubscriptionManager {
|
|||||||
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
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) {
|
async markNotificationsRead(subscriptionId) {
|
||||||
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,13 @@ const createDatabase = (username) => {
|
|||||||
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
|
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
|
||||||
const db = new Dexie(dbName);
|
const db = new Dexie(dbName);
|
||||||
|
|
||||||
db.version(3).stores({
|
db.version(2).stores({
|
||||||
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
|
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
|
||||||
notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
|
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||||
users: "&baseUrl,username",
|
users: "&baseUrl,username",
|
||||||
prefs: "&key",
|
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;
|
return db;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
// 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) => {
|
export const formatMessage = (m) => {
|
||||||
if (m.title) {
|
if (m.title) {
|
||||||
return m.message || "";
|
return m.message;
|
||||||
}
|
}
|
||||||
const emojiList = toEmojis(m.tags);
|
const emojiList = toEmojis(m.tags);
|
||||||
if (emojiList.length > 0) {
|
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;
|
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 icon = "/static/images/ntfy.png";
|
||||||
export const badge = "/static/images/mask-icon.svg";
|
export const badge = "/static/images/mask-icon.svg";
|
||||||
|
|
||||||
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => {
|
||||||
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
||||||
@@ -61,8 +61,8 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
|||||||
badge,
|
badge,
|
||||||
icon,
|
icon,
|
||||||
image,
|
image,
|
||||||
timestamp: message.time * 1000,
|
timestamp: message.time * 1_000,
|
||||||
tag: message.sequence_id || message.id, // Update notification if there is a sequence ID
|
tag: subscriptionId,
|
||||||
renotify: true,
|
renotify: true,
|
||||||
silent: false,
|
silent: false,
|
||||||
// This is used by the notification onclick event
|
// This is used by the notification onclick event
|
||||||
@@ -79,10 +79,3 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messageWithSequenceId = (message) => {
|
|
||||||
if (message.sequenceId) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
return { ...message, sequenceId: message.sequence_id || message.id };
|
|
||||||
};
|
|
||||||
|
|||||||
593
web/src/components/Admin.jsx
Normal file
593
web/src/components/Admin.jsx
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
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,6 +20,7 @@ import Messaging from "./Messaging";
|
|||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
|
import Admin from "./Admin";
|
||||||
import initI18n from "../app/i18n"; // Translations!
|
import initI18n from "../app/i18n"; // Translations!
|
||||||
import prefs, { THEME } from "../app/Prefs";
|
import prefs, { THEME } from "../app/Prefs";
|
||||||
import RTLCacheProvider from "./RTLCacheProvider";
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
@@ -80,6 +81,7 @@ const App = () => {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||||
<Route path={routes.account} element={<Account />} />
|
<Route path={routes.account} element={<Account />} />
|
||||||
|
<Route path={routes.admin} element={<Admin />} />
|
||||||
<Route path={routes.settings} element={<Preferences />} />
|
<Route path={routes.settings} element={<Preferences />} />
|
||||||
<Route path={routes.subscription} element={<SingleSubscription />} />
|
<Route path={routes.subscription} element={<SingleSubscription />} />
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useContext, useState } from "react";
|
|||||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||||
import Person from "@mui/icons-material/Person";
|
import Person from "@mui/icons-material/Person";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||||
@@ -164,6 +165,14 @@ const NavList = (props) => {
|
|||||||
<ListItemText primary={t("nav_button_account")} />
|
<ListItemText primary={t("nav_button_account")} />
|
||||||
</ListItemButton>
|
</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}>
|
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import accountApi from "../app/AccountApi";
|
|||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import prefs from "../app/Prefs";
|
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
|
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||||
@@ -51,29 +50,10 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNotification = async (subscriptionId, notification) => {
|
const handleNotification = async (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);
|
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
||||||
if (added) {
|
if (added) {
|
||||||
await subscriptionManager.notify(subscriptionId, notification);
|
await subscriptionManager.notify(subscriptionId, notification);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessage = async (subscriptionId, message) => {
|
const handleMessage = async (subscriptionId, message) => {
|
||||||
@@ -251,9 +231,7 @@ export const useIsLaunchedPWA = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isIOSStandalone) {
|
if (isIOSStandalone) {
|
||||||
return () => {
|
return () => {}; // No need to listen for events on iOS
|
||||||
// No need to listen for events on iOS
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const handler = (evt) => {
|
const handler = (evt) => {
|
||||||
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
|
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const routes = {
|
|||||||
signup: "/signup",
|
signup: "/signup",
|
||||||
app: config.app_root,
|
app: config.app_root,
|
||||||
account: "/account",
|
account: "/account",
|
||||||
|
admin: "/admin",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
|
|||||||
@@ -5,19 +5,10 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
|
|||||||
const intervalMS = 60 * 60 * 1000;
|
const intervalMS = 60 * 60 * 1000;
|
||||||
|
|
||||||
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
|
// 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({
|
viteRegisterSW({
|
||||||
onRegisteredSW(swUrl, registration) {
|
onRegisteredSW(swUrl, registration) {
|
||||||
console.log("[ServiceWorker] Registered:", { swUrl, registration });
|
|
||||||
|
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
console.warn("[ServiceWorker] No registration returned");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,16 +23,9 @@ const registerSW = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp?.status === 200) {
|
if (resp?.status === 200) await registration.update();
|
||||||
console.log("[ServiceWorker] Updating service worker");
|
|
||||||
await registration.update();
|
|
||||||
}
|
|
||||||
}, intervalMS);
|
}, intervalMS);
|
||||||
},
|
},
|
||||||
onRegisterError(error) {
|
|
||||||
console.error("[ServiceWorker] Registration error:", error);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export default registerSW;
|
export default registerSW;
|
||||||
|
|||||||
Reference in New Issue
Block a user