Merge pull request #717 from binwiederhier/twilio

Twilio
pull/734/head
Philipp C. Heckel 2023-05-17 11:23:09 -04:00 committed by GitHub
commit b154ce5b0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 1619 additions and 300 deletions

View File

@ -71,6 +71,10 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
@ -151,6 +155,10 @@ func execServe(c *cli.Context) error {
smtpServerListen := c.String("smtp-server-listen") smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain") smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
twilioAccount := c.String("twilio-account")
twilioAuthToken := c.String("twilio-auth-token")
twilioFromNumber := c.String("twilio-from-number")
twilioVerifyService := c.String("twilio-verify-service")
totalTopicLimit := c.Int("global-topic-limit") totalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
@ -209,6 +217,8 @@ func execServe(c *cli.Context) error {
return errors.New("cannot set enable-signup without also setting enable-login") return errors.New("cannot set enable-signup without also setting enable-login")
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
return errors.New("if twilio-account is set, twilio-auth-token, twilio-from-number, twilio-verify-service, base-url, and auth-file must also be set")
} }
// Backwards compatibility // Backwards compatibility
@ -308,6 +318,10 @@ func execServe(c *cli.Context) error {
conf.SMTPServerListen = smtpServerListen conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
conf.TwilioAccount = twilioAccount
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioFromNumber = twilioFromNumber
conf.TwilioVerifyService = twilioVerifyService
conf.TotalTopicLimit = totalTopicLimit conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit

View File

@ -18,6 +18,7 @@ const (
defaultMessageLimit = 5000 defaultMessageLimit = 5000
defaultMessageExpiryDuration = "12h" defaultMessageExpiryDuration = "12h"
defaultEmailLimit = 20 defaultEmailLimit = 20
defaultCallLimit = 0
defaultReservationLimit = 3 defaultReservationLimit = 3
defaultAttachmentFileSizeLimit = "15M" defaultAttachmentFileSizeLimit = "15M"
defaultAttachmentTotalSizeLimit = "100M" defaultAttachmentTotalSizeLimit = "100M"
@ -48,6 +49,7 @@ var cmdTier = &cli.Command{
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
&cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
@ -91,6 +93,7 @@ Examples:
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
&cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"},
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
@ -215,6 +218,7 @@ func execTierAdd(c *cli.Context) error {
MessageLimit: c.Int64("message-limit"), MessageLimit: c.Int64("message-limit"),
MessageExpiryDuration: messageExpiryDuration, MessageExpiryDuration: messageExpiryDuration,
EmailLimit: c.Int64("email-limit"), EmailLimit: c.Int64("email-limit"),
CallLimit: c.Int64("call-limit"),
ReservationLimit: c.Int64("reservation-limit"), ReservationLimit: c.Int64("reservation-limit"),
AttachmentFileSizeLimit: attachmentFileSizeLimit, AttachmentFileSizeLimit: attachmentFileSizeLimit,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit, AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
@ -267,6 +271,9 @@ func execTierChange(c *cli.Context) error {
if c.IsSet("email-limit") { if c.IsSet("email-limit") {
tier.EmailLimit = c.Int64("email-limit") tier.EmailLimit = c.Int64("email-limit")
} }
if c.IsSet("call-limit") {
tier.CallLimit = c.Int64("call-limit")
}
if c.IsSet("reservation-limit") { if c.IsSet("reservation-limit") {
tier.ReservationLimit = c.Int64("reservation-limit") tier.ReservationLimit = c.Int64("reservation-limit")
} }
@ -357,6 +364,7 @@ func printTier(c *cli.Context, tier *user.Tier) {
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))

View File

@ -814,6 +814,7 @@ ntfy tier add \
--message-limit=10000 \ --message-limit=10000 \
--message-expiry-duration=24h \ --message-expiry-duration=24h \
--email-limit=50 \ --email-limit=50 \
--call-limit=10 \
--reservation-limit=10 \ --reservation-limit=10 \
--attachment-file-size-limit=100M \ --attachment-file-size-limit=100M \
--attachment-total-size-limit=1G \ --attachment-total-size-limit=1G \
@ -854,6 +855,22 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
billing-contact: "phil@example.com" billing-contact: "phil@example.com"
``` ```
## Phone calls
ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled,
users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header.
See [publishing page](publish.md#phone-calls) for more details.
To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers
are the easiest), and then configure the following options:
* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
* `twilio-from-number` is the outgoing phone number you purchased, e.g. +18775132586
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
## Rate limiting ## Rate limiting
!!! info !!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.

View File

@ -2695,6 +2695,133 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption> <figcaption>Publishing a message via e-mail</figcaption>
</figure> </figure>
## Phone calls
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can use ntfy to call a phone and **read the message out loud using text-to-speech**.
Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have
the ntfy app installed on their phone.
**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is
**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone
number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`.
You may also simply pass `yes` as a value to pick the first of your verified phone numbers.
On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
<figure markdown>
![phone number verification](static/img/web-phone-verify.png)
<figcaption>Phone number verification in the <a href="https://ntfy.sh/account">web app</a></figcaption>
</figure>
As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll
be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues).
!!! info
You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**.
This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or
violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated.
Here's how you use it:
=== "Command line (curl)"
```
curl \
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
-H "Call: +12223334444" \
-d "Your garage seems to be on fire. You should probably check that out." \
ntfy.sh/alerts
```
=== "ntfy CLI"
```
ntfy publish \
--token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
--call=+12223334444 \
alerts "Your garage seems to be on fire. You should probably check that out."
```
=== "HTTP"
``` http
POST /alerts HTTP/1.1
Host: ntfy.sh
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
Call: +12223334444
Your garage seems to be on fire. You should probably check that out.
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/alerts', {
method: 'POST',
body: "Your garage seems to be on fire. You should probably check that out.",
headers: {
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
'Call': '+12223334444'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
strings.NewReader("Your garage seems to be on fire. You should probably check that out."))
req.Header.Set("Call", "+12223334444")
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/alerts"
Headers = @{
Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
Call = "+12223334444"
}
Body = "Your garage seems to be on fire. You should probably check that out."
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
requests.post("https://ntfy.sh/alerts",
data="Your garage seems to be on fire. You should probably check that out.",
headers={
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2",
"Call": "+12223334444"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" .
"Call: +12223334444",
'content' => 'Your garage seems to be on fire. You should probably check that out.'
]
]));
```
Here's what a phone call from ntfy sounds like:
<audio controls>
<source src="../static/audio/ntfy-phone-call.mp3" type="audio/mpeg">
<source src="../static/audio/ntfy-phone-call.ogg" type="audio/ogg">
</audio>
Audio transcript:
> You have a notification from ntfy on topic alerts.
> Message: Your garage seems to be on fire. You should probably check that out. End message.
> This message was sent by user phil. It will be repeated up to three times.
## Authentication ## Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them. may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
@ -3314,17 +3441,18 @@ There are a few limitations to the API to prevent abuse and to keep the server h
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
but just in case, let's list them all: but just in case, let's list them all:
| Limit | Description | | Limit | Description |
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | | **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | | **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | | **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | | **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. |
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | | **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | | **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | | **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | | **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | | **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
@ -3353,6 +3481,7 @@ table in their canonical form.
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |

View File

@ -1182,6 +1182,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Features:** **Features:**
* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)
* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) * Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

2
go.mod
View File

@ -33,7 +33,7 @@ require (
require ( require (
cloud.google.com/go v0.110.2 // indirect cloud.google.com/go v0.110.2 // indirect
cloud.google.com/go/compute v1.19.2 // indirect cloud.google.com/go/compute v1.19.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.0.1 // indirect cloud.google.com/go/iam v1.0.1 // indirect
cloud.google.com/go/longrunning v0.4.2 // indirect cloud.google.com/go/longrunning v0.4.2 // indirect

22
go.sum
View File

@ -1,23 +1,15 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA=
cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw=
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY=
cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc=
cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew=
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU= cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE= cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
@ -147,8 +139,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.17.0 h1:qVWSzmADr6gudznuAcPjB9ewzgxfyIhBCkyTbkxJcCw=
github.com/stripe/stripe-go/v74 v74.17.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw= github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY= github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
@ -163,8 +153,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -186,14 +174,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -241,8 +225,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow=
google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View File

@ -41,34 +41,34 @@ func newEvent() *Event {
// Fatal logs the event as FATAL, and exits the program with exit code 1 // Fatal logs the event as FATAL, and exits the program with exit code 1
func (e *Event) Fatal(message string, v ...any) { func (e *Event) Fatal(message string, v ...any) {
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...) e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
os.Exit(1) os.Exit(1)
} }
// Error logs the event with log level error // Error logs the event with log level error
func (e *Event) Error(message string, v ...any) { func (e *Event) Error(message string, v ...any) *Event {
e.maybeLog(ErrorLevel, message, v...) return e.Log(ErrorLevel, message, v...)
} }
// Warn logs the event with log level warn // Warn logs the event with log level warn
func (e *Event) Warn(message string, v ...any) { func (e *Event) Warn(message string, v ...any) *Event {
e.maybeLog(WarnLevel, message, v...) return e.Log(WarnLevel, message, v...)
} }
// Info logs the event with log level info // Info logs the event with log level info
func (e *Event) Info(message string, v ...any) { func (e *Event) Info(message string, v ...any) *Event {
e.maybeLog(InfoLevel, message, v...) return e.Log(InfoLevel, message, v...)
} }
// Debug logs the event with log level debug // Debug logs the event with log level debug
func (e *Event) Debug(message string, v ...any) { func (e *Event) Debug(message string, v ...any) *Event {
e.maybeLog(DebugLevel, message, v...) return e.Log(DebugLevel, message, v...)
} }
// Trace logs the event with log level trace // Trace logs the event with log level trace
func (e *Event) Trace(message string, v ...any) { func (e *Event) Trace(message string, v ...any) *Event {
e.maybeLog(TraceLevel, message, v...) return e.Log(TraceLevel, message, v...)
} }
// Tag adds a "tag" field to the log event // Tag adds a "tag" field to the log event
@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event {
return e return e
} }
// FieldIf adds a custom field and value to the log event if the given level is loggable
func (e *Event) FieldIf(key string, value any, level Level) *Event {
if e.Loggable(level) {
return e.Field(key, value)
}
return e
}
// Fields adds a map of fields to the log event // Fields adds a map of fields to the log event
func (e *Event) Fields(fields Context) *Event { func (e *Event) Fields(fields Context) *Event {
if e.fields == nil { if e.fields == nil {
@ -138,7 +146,7 @@ func (e *Event) With(contexters ...Contexter) *Event {
// to determine if they match. This is super complicated, but required for efficiency. // to determine if they match. This is super complicated, but required for efficiency.
func (e *Event) Render(l Level, message string, v ...any) string { func (e *Event) Render(l Level, message string, v ...any) string {
appliedContexters := e.maybeApplyContexters() appliedContexters := e.maybeApplyContexters()
if !e.shouldLog(l) { if !e.Loggable(l) {
return "" return ""
} }
e.Message = fmt.Sprintf(message, v...) e.Message = fmt.Sprintf(message, v...)
@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string {
return e.String() return e.String()
} }
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string // Log logs the event to the defined output, or does nothing if Render returns an empty string
func (e *Event) maybeLog(l Level, message string, v ...any) { func (e *Event) Log(l Level, message string, v ...any) *Event {
if m := e.Render(l, message, v...); m != "" { if m := e.Render(l, message, v...); m != "" {
log.Println(m) log.Println(m)
} }
return e
} }
// Loggable returns true if the given log level is lower or equal to the current log level // Loggable returns true if the given log level is lower or equal to the current log level
@ -199,10 +208,6 @@ func (e *Event) String() string {
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
} }
func (e *Event) shouldLog(l Level) bool {
return e.globalLevelWithOverride() <= l
}
func (e *Event) globalLevelWithOverride() Level { func (e *Event) globalLevelWithOverride() Level {
mu.RLock() mu.RLock()
l, ov := level, overrides l, ov := level, overrides

View File

@ -198,6 +198,30 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
require.Equal(t, "", File()) require.Equal(t, "", File())
} }
func TestLog_FieldIf(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetLevel(DebugLevel)
SetFormat(JSONFormat)
Time(time.Unix(11, 0).UTC()).
FieldIf("trace_field", "manager", TraceLevel). // This is not logged
Field("tag", "manager").
Debug("trace_field is not logged")
SetLevel(TraceLevel)
Time(time.Unix(12, 0).UTC()).
FieldIf("trace_field", "manager", TraceLevel). // Now it is logged
Field("tag", "manager").
Debug("trace_field is logged")
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"}
{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"}
`
require.Equal(t, expected, out.String())
}
func TestLog_UsingStdLogger_JSON(t *testing.T) { func TestLog_UsingStdLogger_JSON(t *testing.T) {
t.Cleanup(resetState) t.Cleanup(resetState)

View File

@ -105,6 +105,12 @@ type Config struct {
SMTPServerListen string SMTPServerListen string
SMTPServerDomain string SMTPServerDomain string
SMTPServerAddrPrefix string SMTPServerAddrPrefix string
TwilioAccount string
TwilioAuthToken string
TwilioFromNumber string
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
MetricsEnable bool MetricsEnable bool
MetricsListenHTTP string MetricsListenHTTP string
ProfileListenHTTP string ProfileListenHTTP string
@ -183,6 +189,12 @@ func NewConfig() *Config {
SMTPServerListen: "", SMTPServerListen: "",
SMTPServerDomain: "", SMTPServerDomain: "",
SMTPServerAddrPrefix: "", SMTPServerAddrPrefix: "",
TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
TwilioAccount: "",
TwilioAuthToken: "",
TwilioFromNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
MessageLimit: DefaultMessageLengthLimit, MessageLimit: DefaultMessageLengthLimit,
MinDelay: DefaultMinDelay, MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay, MaxDelay: DefaultMaxDelay,

View File

@ -108,12 +108,20 @@ var (
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", 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}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil} errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil} errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
@ -126,6 +134,7 @@ var (
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}

View File

@ -20,6 +20,7 @@ const (
tagFirebase = "firebase" tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email tagEmail = "email" // Send email
tagTwilio = "twilio"
tagFileCache = "file_cache" tagFileCache = "file_cache"
tagMessageCache = "message_cache" tagMessageCache = "message_cache"
tagStripe = "stripe" tagStripe = "stripe"

View File

@ -90,6 +90,8 @@ var (
apiAccountSettingsPath = "/v1/account/settings" apiAccountSettingsPath = "/v1/account/settings"
apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountSubscriptionPath = "/v1/account/subscription"
apiAccountReservationPath = "/v1/account/reservation" apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@ -100,6 +102,7 @@ var (
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?://`)
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
//go:embed site //go:embed site
webFs embed.FS webFs embed.FS
@ -461,6 +464,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
return s.handleStats(w, r, v) return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@ -540,6 +549,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup, EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "", EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableReservations: s.config.EnableReservations, EnableReservations: s.config.EnableReservations,
BillingContact: s.config.BillingContact, BillingContact: s.config.BillingContact,
DisallowedTopics: s.config.DisallowedTopics, DisallowedTopics: s.config.DisallowedTopics,
@ -683,7 +694,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, err return nil, err
} }
m := newDefaultMessage(t.ID, "") m := newDefaultMessage(t.ID, "")
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m) cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
if e != nil { if e != nil {
return nil, e.With(t) return nil, e.With(t)
} }
@ -697,6 +708,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, errHTTPTooManyRequestsLimitMessages.With(t) return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() { } else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t) return nil, errHTTPTooManyRequestsLimitEmails.With(t)
} else if call != "" {
var httpErr *errHTTP
call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil {
return nil, httpErr.With(t)
} else if !vrate.CallAllowed() {
return nil, errHTTPTooManyRequestsLimitCalls.With(t)
}
} }
if m.PollID != "" { if m.PollID != "" {
m = newPollRequestMessage(t.ID, m.PollID) m = newPollRequestMessage(t.ID, m.PollID)
@ -721,6 +740,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
"message_firebase": firebase, "message_firebase": firebase,
"message_unifiedpush": unifiedpush, "message_unifiedpush": unifiedpush,
"message_email": email, "message_email": email,
"message_call": call,
}) })
if ev.IsTrace() { if ev.IsTrace() {
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message") ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
@ -737,6 +757,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if s.smtpSender != nil && email != "" { if s.smtpSender != nil && email != "" {
go s.sendEmail(v, m, email) go s.sendEmail(v, m, email)
} }
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call)
}
if s.config.UpstreamBaseURL != "" { if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m) go s.forwardPollRequest(v, m)
} }
@ -846,7 +869,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
} }
} }
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) { func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
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 = maybeDecodeHeader(readParam(r, "x-title", "title", "t")) m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
@ -862,7 +885,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
} }
if attach != "" { if attach != "" {
if !urlRegex.MatchString(attach) { if !urlRegex.MatchString(attach) {
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
} }
m.Attachment.URL = attach m.Attachment.URL = attach
if m.Attachment.Name == "" { if m.Attachment.Name == "" {
@ -880,13 +903,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
} }
if icon != "" { if icon != "" {
if !urlRegex.MatchString(icon) { if !urlRegex.MatchString(icon) {
return false, false, "", false, errHTTPBadRequestIconURLInvalid return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
} }
m.Icon = icon m.Icon = icon
} }
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if s.smtpSender == nil && email != "" { if s.smtpSender == nil && email != "" {
return false, false, "", false, errHTTPBadRequestEmailDisabled return false, false, "", "", false, errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
} }
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" { if messageStr != "" {
@ -895,7 +924,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
var e error var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if e != nil { if e != nil {
return false, false, "", false, errHTTPBadRequestPriorityInvalid return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
} }
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
for i, t := range m.Tags { for i, t := range m.Tags {
@ -904,18 +933,21 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" { if delayStr != "" {
if !cache { if !cache {
return false, false, "", false, errHTTPBadRequestDelayNoCache return false, false, "", "", false, errHTTPBadRequestDelayNoCache
} }
if email != "" { if email != "" {
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
if call != "" {
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
} }
delay, err := util.ParseFutureTime(delayStr, time.Now()) delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil { if err != nil {
return false, false, "", false, errHTTPBadRequestDelayCannotParse return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooSmall return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooLarge return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
} }
m.Time = delay.Unix() m.Time = delay.Unix()
} }
@ -923,7 +955,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if actionsStr != "" { if actionsStr != "" {
m.Actions, e = parseActions(actionsStr) m.Actions, e = parseActions(actionsStr)
if e != nil { if e != nil {
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
} }
} }
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
@ -937,7 +969,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
cache = false cache = false
email = "" email = ""
} }
return cache, firebase, email, unifiedpush, nil return cache, firebase, email, call, unifiedpush, nil
} }
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.

View File

@ -144,6 +144,18 @@
# smtp-server-domain: # smtp-server-domain:
# smtp-server-addr-prefix: # smtp-server-addr-prefix:
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
# - twilio-from-number is the outgoing phone number you purchased, e.g. +18775132586
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
#
# twilio-account:
# twilio-auth-token:
# twilio-from-number:
# twilio-verify-service:
# Interval in which keepalive messages are sent to the client. This is to prevent # Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity. # intermediaries closing the connection for inactivity.
# #

View File

@ -56,6 +56,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
Messages: limits.MessageLimit, Messages: limits.MessageLimit,
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
Emails: limits.EmailLimit, Emails: limits.EmailLimit,
Calls: limits.CallLimit,
Reservations: limits.ReservationsLimit, Reservations: limits.ReservationsLimit,
AttachmentTotalSize: limits.AttachmentTotalSizeLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
AttachmentFileSize: limits.AttachmentFileSizeLimit, AttachmentFileSize: limits.AttachmentFileSizeLimit,
@ -67,6 +68,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
MessagesRemaining: stats.MessagesRemaining, MessagesRemaining: stats.MessagesRemaining,
Emails: stats.Emails, Emails: stats.Emails,
EmailsRemaining: stats.EmailsRemaining, EmailsRemaining: stats.EmailsRemaining,
Calls: stats.Calls,
CallsRemaining: stats.CallsRemaining,
Reservations: stats.Reservations, Reservations: stats.Reservations,
ReservationsRemaining: stats.ReservationsRemaining, ReservationsRemaining: stats.ReservationsRemaining,
AttachmentTotalSize: stats.AttachmentTotalSize, AttachmentTotalSize: stats.AttachmentTotalSize,
@ -105,17 +108,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
} }
} }
reservations, err := s.userManager.Reservations(u.Name) if s.config.EnableReservations {
if err != nil { reservations, err := s.userManager.Reservations(u.Name)
return err if err != nil {
} return err
if len(reservations) > 0 { }
response.Reservations = make([]*apiAccountReservation, 0) if len(reservations) > 0 {
for _, r := range reservations { response.Reservations = make([]*apiAccountReservation, 0)
response.Reservations = append(response.Reservations, &apiAccountReservation{ for _, r := range reservations {
Topic: r.Topic, response.Reservations = append(response.Reservations, &apiAccountReservation{
Everyone: r.Everyone.String(), Topic: r.Topic,
}) Everyone: r.Everyone.String(),
})
}
} }
} }
tokens, err := s.userManager.Tokens(u.ID) tokens, err := s.userManager.Tokens(u.ID)
@ -138,6 +143,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
}) })
} }
} }
if s.config.TwilioAccount != "" {
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return err
}
if len(phoneNumbers) > 0 {
response.PhoneNumbers = phoneNumbers
}
}
} else { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
@ -511,6 +525,72 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
return nil return nil
} }
func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
} else if req.Channel != "sms" && req.Channel != "call" {
return errHTTPBadRequestPhoneNumberVerifyChannelInvalid
}
// Check user is allowed to add phone numbers
if u == nil || (u.IsUser() && u.Tier == nil) {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier.CallLimit == 0 {
return errHTTPUnauthorized
}
// Check if phone number exists
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return err
} else if util.Contains(phoneNumbers, req.Number) {
return errHTTPConflictPhoneNumberExists
}
// Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) { func (s *Server) publishSyncEventAsync(v *visitor) {
go func() { go func() {

View File

@ -151,6 +151,8 @@ func TestAccount_Get_Anonymous(t *testing.T) {
require.Equal(t, int64(1004), account.Stats.MessagesRemaining) require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
require.Equal(t, int64(0), account.Stats.Emails) require.Equal(t, int64(0), account.Stats.Emails)
require.Equal(t, int64(24), account.Stats.EmailsRemaining) require.Equal(t, int64(24), account.Stats.EmailsRemaining)
require.Equal(t, int64(0), account.Stats.Calls)
require.Equal(t, int64(0), account.Stats.CallsRemaining)
rr = request(t, s, "POST", "/mytopic", "", nil) rr = request(t, s, "POST", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@ -498,6 +500,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
conf.EnableReservations = true
conf.TwilioAccount = "dummy"
s := newTestServer(t, conf) s := newTestServer(t, conf)
// Create user // Create user
@ -510,6 +514,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
MessageLimit: 123, MessageLimit: 123,
MessageExpiryDuration: 86400 * time.Second, MessageExpiryDuration: 86400 * time.Second,
EmailLimit: 32, EmailLimit: 32,
CallLimit: 10,
ReservationLimit: 2, ReservationLimit: 2,
AttachmentFileSizeLimit: 1231231, AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123, AttachmentTotalSizeLimit: 123123,
@ -551,6 +556,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
require.Equal(t, int64(123), account.Limits.Messages) require.Equal(t, int64(123), account.Limits.Messages)
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
require.Equal(t, int64(32), account.Limits.Emails) require.Equal(t, int64(32), account.Limits.Emails)
require.Equal(t, int64(10), account.Limits.Calls)
require.Equal(t, int64(2), account.Limits.Reservations) require.Equal(t, int64(2), account.Limits.Reservations)
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)

View File

@ -15,6 +15,8 @@ var (
metricEmailsPublishedFailure prometheus.Counter metricEmailsPublishedFailure prometheus.Counter
metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter
metricEmailsReceivedFailure prometheus.Counter metricEmailsReceivedFailure prometheus.Counter
metricCallsMadeSuccess prometheus.Counter
metricCallsMadeFailure prometheus.Counter
metricUnifiedPushPublishedSuccess prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter
metricMatrixPublishedSuccess prometheus.Counter metricMatrixPublishedSuccess prometheus.Counter
metricMatrixPublishedFailure prometheus.Counter metricMatrixPublishedFailure prometheus.Counter
@ -57,6 +59,12 @@ func initMetrics() {
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure", Name: "ntfy_emails_received_failure",
}) })
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success", Name: "ntfy_unifiedpush_published_success",
}) })
@ -95,6 +103,8 @@ func initMetrics() {
metricEmailsPublishedFailure, metricEmailsPublishedFailure,
metricEmailsReceivedSuccess, metricEmailsReceivedSuccess,
metricEmailsReceivedFailure, metricEmailsReceivedFailure,
metricCallsMadeSuccess,
metricCallsMadeFailure,
metricUnifiedPushPublishedSuccess, metricUnifiedPushPublishedSuccess,
metricMatrixPublishedSuccess, metricMatrixPublishedSuccess,
metricMatrixPublishedFailure, metricMatrixPublishedFailure,

View File

@ -85,6 +85,15 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc {
}) })
} }
func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.TwilioAccount == "" || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil { if s.config.StripeSecretKey == "" || s.stripe == nil {

View File

@ -68,6 +68,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Messages: freeTier.MessageLimit, Messages: freeTier.MessageLimit,
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
Emails: freeTier.EmailLimit, Emails: freeTier.EmailLimit,
Calls: freeTier.CallLimit,
Reservations: freeTier.ReservationsLimit, Reservations: freeTier.ReservationsLimit,
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
AttachmentFileSize: freeTier.AttachmentFileSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
@ -96,6 +97,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Messages: tier.MessageLimit, Messages: tier.MessageLimit,
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
Emails: tier.EmailLimit, Emails: tier.EmailLimit,
Calls: tier.CallLimit,
Reservations: tier.ReservationLimit, Reservations: tier.ReservationLimit,
AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
AttachmentFileSize: tier.AttachmentFileSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit,

View File

@ -1190,7 +1190,20 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
"E-Mail": "test@example.com", "E-Mail": "test@example.com",
"Delay": "20 min", "Delay": "20 min",
}) })
require.Equal(t, 400, response.Code) require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishDelayedCall_Fail(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"Call": "yes",
"Delay": "20 min",
})
require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code)
} }
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {

View File

@ -0,0 +1,173 @@
package server
import (
"bytes"
"encoding/xml"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/url"
"strings"
)
const (
twilioCallFormat = `
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic %s. Message:
<break time="1s"/>
%s
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user %s. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`
)
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error.
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed
}
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(phoneNumbers) == 0 {
return "", errHTTPBadRequestPhoneNumberNotVerified
}
if toBool(phoneNumber) {
return phoneNumbers[0], nil
} else if util.Contains(phoneNumbers, phoneNumber) {
return phoneNumber, nil
}
for _, p := range phoneNumbers {
if p == phoneNumber {
return phoneNumber, nil
}
}
return "", errHTTPBadRequestPhoneNumberNotVerified
}
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller.
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
u, sender := v.User(), m.Sender.String()
if u != nil {
sender = u.Name
}
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
data := url.Values{}
data.Set("From", s.config.TwilioFromNumber)
data.Set("To", to)
data.Set("Twiml", body)
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
response, err := s.callPhoneInternal(data)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
minc(metricCallsMadeFailure)
return
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
minc(metricCallsMadeSuccess)
}
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(response), nil
}
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
}
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return errHTTPGonePhoneVerificationExpired
}
return errHTTPInternalError
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
}

View File

@ -0,0 +1,264 @@
package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
var called, verified atomic.Bool
var code atomic.Pointer[string]
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
if code.Load() != nil {
t.Fatal("Should be only called once")
}
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
code.Store(util.String("123456"))
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
if verified.Load() {
t.Fatal("Should be only called once")
}
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
verified.Store(true)
} else {
t.Fatal("Unexpected path:", r.URL.Path)
}
}))
defer twilioVerifyServer.Close()
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioCallsServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
c.TwilioCallsBaseURL = twilioCallsServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
c.TwilioVerifyService = "VA1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
// Send verification code for phone number
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return *code.Load() == "123456"
})
// Add phone number with code
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return verified.Load()
})
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 1, len(phoneNumbers))
require.Equal(t, "+12223334444", phoneNumbers[0])
// Do the thing
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
// Remove the phone number
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Verify the phone number is gone from the DB
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 0, len(phoneNumbers))
}
func TestServer_Twilio_Call_Success(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
}
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes", // <<<------
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
}
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "http://dummy.invalid"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+invalid",
})
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+123123",
})
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+1234",
})
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
}

View File

@ -311,6 +311,16 @@ type apiAccountTokenResponse struct {
Expires int64 `json:"expires,omitempty"` // Unix timestamp Expires int64 `json:"expires,omitempty"` // Unix timestamp
} }
type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"`
Channel string `json:"channel"`
}
type apiAccountPhoneNumberAddRequest struct {
Number string `json:"number"`
Code string `json:"code"` // Only set when adding a phone number
}
type apiAccountTier struct { type apiAccountTier struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
@ -321,6 +331,7 @@ type apiAccountLimits struct {
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
Calls int64 `json:"calls"`
Reservations int64 `json:"reservations"` Reservations int64 `json:"reservations"`
AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentFileSize int64 `json:"attachment_file_size"` AttachmentFileSize int64 `json:"attachment_file_size"`
@ -333,6 +344,8 @@ type apiAccountStats struct {
MessagesRemaining int64 `json:"messages_remaining"` MessagesRemaining int64 `json:"messages_remaining"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
EmailsRemaining int64 `json:"emails_remaining"` EmailsRemaining int64 `json:"emails_remaining"`
Calls int64 `json:"calls"`
CallsRemaining int64 `json:"calls_remaining"`
Reservations int64 `json:"reservations"` Reservations int64 `json:"reservations"`
ReservationsRemaining int64 `json:"reservations_remaining"` ReservationsRemaining int64 `json:"reservations_remaining"`
AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentTotalSize int64 `json:"attachment_total_size"`
@ -362,6 +375,7 @@ type apiAccountResponse struct {
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
@ -379,6 +393,8 @@ type apiConfigResponse struct {
EnableLogin bool `json:"enable_login"` EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"` EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableReservations bool `json:"enable_reservations"` EnableReservations bool `json:"enable_reservations"`
BillingContact string `json:"billing_contact"` BillingContact string `json:"billing_contact"`
DisallowedTopics []string `json:"disallowed_topics"` DisallowedTopics []string `json:"disallowed_topics"`

View File

@ -18,6 +18,14 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
if value == "" { if value == "" {
return defaultValue return defaultValue
} }
return toBool(value)
}
func isBoolValue(value string) bool {
return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false"
}
func toBool(value string) bool {
return value == "1" || value == "yes" || value == "true" return value == "1" || value == "yes" || value == "true"
} }

View File

@ -24,6 +24,10 @@ const (
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise // This number is zero, and changing it may have unintended consequences in the web app, or otherwise
visitorDefaultReservationsLimit = int64(0) visitorDefaultReservationsLimit = int64(0)
// visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make.
// This number is zero, because phone numbers have to be verified first.
visitorDefaultCallsLimit = int64(0)
) )
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
@ -56,6 +60,7 @@ type visitor struct {
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
messagesLimiter *util.FixedLimiter // Rate limiter for messages messagesLimiter *util.FixedLimiter // Rate limiter for messages
emailsLimiter *util.RateLimiter // Rate limiter for emails emailsLimiter *util.RateLimiter // Rate limiter for emails
callsLimiter *util.FixedLimiter // Rate limiter for calls
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
@ -79,6 +84,7 @@ type visitorLimits struct {
EmailLimit int64 EmailLimit int64
EmailLimitBurst int EmailLimitBurst int
EmailLimitReplenish rate.Limit EmailLimitReplenish rate.Limit
CallLimit int64
ReservationsLimit int64 ReservationsLimit int64
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64
@ -91,6 +97,8 @@ type visitorStats struct {
MessagesRemaining int64 MessagesRemaining int64
Emails int64 Emails int64
EmailsRemaining int64 EmailsRemaining int64
Calls int64
CallsRemaining int64
Reservations int64 Reservations int64
ReservationsRemaining int64 ReservationsRemaining int64
AttachmentTotalSize int64 AttachmentTotalSize int64
@ -107,10 +115,11 @@ const (
) )
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messages, emails int64 var messages, emails, calls int64
if user != nil { if user != nil {
messages = user.Stats.Messages messages = user.Stats.Messages
emails = user.Stats.Emails emails = user.Stats.Emails
calls = user.Stats.Calls
} }
v := &visitor{ v := &visitor{
config: conf, config: conf,
@ -124,11 +133,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
requestLimiter: nil, // Set in resetLimiters requestLimiter: nil, // Set in resetLimiters
messagesLimiter: nil, // Set in resetLimiters, may be nil messagesLimiter: nil, // Set in resetLimiters, may be nil
emailsLimiter: nil, // Set in resetLimiters emailsLimiter: nil, // Set in resetLimiters
callsLimiter: nil, // Set in resetLimiters, may be nil
bandwidthLimiter: nil, // Set in resetLimiters bandwidthLimiter: nil, // Set in resetLimiters
accountLimiter: nil, // Set in resetLimiters, may be nil accountLimiter: nil, // Set in resetLimiters, may be nil
authLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil
} }
v.resetLimitersNoLock(messages, emails, false) v.resetLimitersNoLock(messages, emails, calls, false)
return v return v
} }
@ -147,12 +157,19 @@ func (v *visitor) contextNoLock() log.Context {
"visitor_messages": info.Stats.Messages, "visitor_messages": info.Stats.Messages,
"visitor_messages_limit": info.Limits.MessageLimit, "visitor_messages_limit": info.Limits.MessageLimit,
"visitor_messages_remaining": info.Stats.MessagesRemaining, "visitor_messages_remaining": info.Stats.MessagesRemaining,
"visitor_emails": info.Stats.Emails,
"visitor_emails_limit": info.Limits.EmailLimit,
"visitor_emails_remaining": info.Stats.EmailsRemaining,
"visitor_request_limiter_limit": v.requestLimiter.Limit(), "visitor_request_limiter_limit": v.requestLimiter.Limit(),
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(), "visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
} }
if v.config.SMTPSenderFrom != "" {
fields["visitor_emails"] = info.Stats.Emails
fields["visitor_emails_limit"] = info.Limits.EmailLimit
fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
}
if v.config.TwilioAccount != "" {
fields["visitor_calls"] = info.Stats.Calls
fields["visitor_calls_limit"] = info.Limits.CallLimit
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
}
if v.authLimiter != nil { if v.authLimiter != nil {
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
@ -216,6 +233,12 @@ func (v *visitor) EmailAllowed() bool {
return v.emailsLimiter.Allow() return v.emailsLimiter.Allow()
} }
func (v *visitor) CallAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
return v.callsLimiter.Allow()
}
func (v *visitor) SubscriptionAllowed() bool { func (v *visitor) SubscriptionAllowed() bool {
v.mu.RLock() // limiters could be replaced! v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock() defer v.mu.RUnlock()
@ -296,6 +319,7 @@ func (v *visitor) Stats() *user.Stats {
return &user.Stats{ return &user.Stats{
Messages: v.messagesLimiter.Value(), Messages: v.messagesLimiter.Value(),
Emails: v.emailsLimiter.Value(), Emails: v.emailsLimiter.Value(),
Calls: v.callsLimiter.Value(),
} }
} }
@ -304,6 +328,7 @@ func (v *visitor) ResetStats() {
defer v.mu.RUnlock() defer v.mu.RUnlock()
v.emailsLimiter.Reset() v.emailsLimiter.Reset()
v.messagesLimiter.Reset() v.messagesLimiter.Reset()
v.callsLimiter.Reset()
} }
// User returns the visitor user, or nil if there is none // User returns the visitor user, or nil if there is none
@ -334,11 +359,11 @@ func (v *visitor) SetUser(u *user.User) {
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
v.user = u // u may be nil! v.user = u // u may be nil!
if shouldResetLimiters { if shouldResetLimiters {
var messages, emails int64 var messages, emails, calls int64
if u != nil { if u != nil {
messages, emails = u.Stats.Messages, u.Stats.Emails messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls
} }
v.resetLimitersNoLock(messages, emails, true) v.resetLimitersNoLock(messages, emails, calls, true)
} }
} }
@ -353,11 +378,12 @@ func (v *visitor) MaybeUserID() string {
return "" return ""
} }
func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) { func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {
limits := v.limitsNoLock() limits := v.limitsNoLock()
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
if v.user == nil { if v.user == nil {
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
@ -370,6 +396,7 @@ func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
Messages: messages, Messages: messages,
Emails: emails, Emails: emails,
Calls: calls,
}) })
} }
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
@ -398,6 +425,7 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
EmailLimit: tier.EmailLimit, EmailLimit: tier.EmailLimit,
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
CallLimit: tier.CallLimit,
ReservationsLimit: tier.ReservationLimit, ReservationsLimit: tier.ReservationLimit,
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
@ -420,6 +448,7 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
CallLimit: visitorDefaultCallsLimit,
ReservationsLimit: visitorDefaultReservationsLimit, ReservationsLimit: visitorDefaultReservationsLimit,
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
@ -465,12 +494,15 @@ func (v *visitor) Info() (*visitorInfo, error) {
func (v *visitor) infoLightNoLock() *visitorInfo { func (v *visitor) infoLightNoLock() *visitorInfo {
messages := v.messagesLimiter.Value() messages := v.messagesLimiter.Value()
emails := v.emailsLimiter.Value() emails := v.emailsLimiter.Value()
calls := v.callsLimiter.Value()
limits := v.limitsNoLock() limits := v.limitsNoLock()
stats := &visitorStats{ stats := &visitorStats{
Messages: messages, Messages: messages,
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
Emails: emails, Emails: emails,
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
Calls: calls,
CallsRemaining: zeroIfNegative(limits.CallLimit - calls),
} }
return &visitorInfo{ return &visitorInfo{
Limits: limits, Limits: limits,

View File

@ -6,7 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver "github.com/mattn/go-sqlite3"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
@ -55,6 +55,7 @@ const (
messages_limit INT NOT NULL, messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL, messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL, emails_limit INT NOT NULL,
calls_limit INT NOT NULL,
reservations_limit INT NOT NULL, reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL,
@ -76,6 +77,7 @@ const (
sync_topic TEXT NOT NULL, sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0), stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT, stripe_customer_id TEXT,
stripe_subscription_id TEXT, stripe_subscription_id TEXT,
stripe_subscription_status TEXT, stripe_subscription_status TEXT,
@ -109,6 +111,12 @@ const (
PRIMARY KEY (user_id, token), PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
@ -123,26 +131,26 @@ const (
` `
selectUserByIDQuery = ` selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ? WHERE u.id = ?
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
JOIN user_token tk on u.id = tk.user_id JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
` `
selectUserByStripeCustomerIDQuery = ` selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ? WHERE u.stripe_customer_id = ?
@ -173,8 +181,8 @@ const (
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
deleteUserQuery = `DELETE FROM user WHERE user = ?` deleteUserQuery = `DELETE FROM user WHERE user = ?`
@ -262,26 +270,30 @@ const (
) )
` `
selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?`
insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
insertTierQuery = ` insertTierQuery = `
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
updateTierQuery = ` updateTierQuery = `
UPDATE tier UPDATE tier
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
WHERE code = ? WHERE code = ?
` `
selectTiersQuery = ` selectTiersQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier FROM tier
` `
selectTierByCodeQuery = ` selectTierByCodeQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier FROM tier
WHERE code = ? WHERE code = ?
` `
selectTierByPriceIDQuery = ` selectTierByPriceIDQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier FROM tier
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
` `
@ -298,7 +310,7 @@ const (
// Schema management queries // Schema management queries
const ( const (
currentSchemaVersion = 3 currentSchemaVersion = 4
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@ -396,12 +408,25 @@ const (
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
` `
// 3 -> 4
migrate3To4UpdateQueries = `
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
) )
var ( var (
migrations = map[int]func(db *sql.DB) error{ migrations = map[int]func(db *sql.DB) error{
1: migrateFrom1, 1: migrateFrom1,
2: migrateFrom2, 2: migrateFrom2,
3: migrateFrom3,
} }
) )
@ -623,6 +648,56 @@ func (a *Manager) RemoveExpiredTokens() error {
return nil return nil
} }
// PhoneNumbers returns all phone numbers for the user with the given user ID
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
rows, err := a.db.Query(selectPhoneNumbersQuery, userID)
if err != nil {
return nil, err
}
defer rows.Close()
phoneNumbers := make([]string, 0)
for {
phoneNumber, err := a.readPhoneNumber(rows)
if err == ErrPhoneNumberNotFound {
break
} else if err != nil {
return nil, err
}
phoneNumbers = append(phoneNumbers, phoneNumber)
}
return phoneNumbers, nil
}
func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
var phoneNumber string
if !rows.Next() {
return "", ErrPhoneNumberNotFound
}
if err := rows.Scan(&phoneNumber); err != nil {
return "", err
} else if err := rows.Err(); err != nil {
return "", err
}
return phoneNumber, nil
}
// AddPhoneNumber adds a phone number to the user with the given user ID
func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return ErrPhoneNumberExists
}
return err
}
return nil
}
// RemovePhoneNumber deletes a phone number from the user with the given user ID
func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error {
_, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber)
return err
}
// RemoveDeletedUsers deletes all users that have been marked deleted for // RemoveDeletedUsers deletes all users that have been marked deleted for
func (a *Manager) RemoveDeletedUsers() error { func (a *Manager) RemoveDeletedUsers() error {
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {
@ -705,9 +780,10 @@ func (a *Manager) writeUserStatsQueue() error {
"user_id": userID, "user_id": userID,
"messages_count": update.Messages, "messages_count": update.Messages,
"emails_count": update.Emails, "emails_count": update.Emails,
"calls_count": update.Calls,
}). }).
Trace("Updating stats for user %s", userID) Trace("Updating stats for user %s", userID)
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, userID); err != nil { if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil {
return err return err
} }
} }
@ -789,6 +865,9 @@ func (a *Manager) AddUser(username, password string, role Role) error {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength) userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return ErrUserExists
}
return err return err
} }
return nil return nil
@ -916,12 +995,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close() defer rows.Close()
var id, username, hash, role, prefs, syncTopic string var id, username, hash, role, prefs, syncTopic string
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails int64 var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -936,6 +1015,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
Stats: &Stats{ Stats: &Stats{
Messages: messages, Messages: messages,
Emails: emails, Emails: emails,
Calls: calls,
}, },
Billing: &Billing{ Billing: &Billing{
StripeCustomerID: stripeCustomerID.String, // May be empty StripeCustomerID: stripeCustomerID.String, // May be empty
@ -959,6 +1039,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
MessageLimit: messagesLimit.Int64, MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64, EmailLimit: emailsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64, ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
@ -1291,7 +1372,7 @@ func (a *Manager) AddTier(tier *Tier) error {
if tier.ID == "" { if tier.ID == "" {
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
} }
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
return err return err
} }
return nil return nil
@ -1299,7 +1380,7 @@ func (a *Manager) AddTier(tier *Tier) error {
// UpdateTier updates a tier's properties in the database // UpdateTier updates a tier's properties in the database
func (a *Manager) UpdateTier(tier *Tier) error { func (a *Manager) UpdateTier(tier *Tier) error {
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
return err return err
} }
return nil return nil
@ -1368,11 +1449,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
var id, code, name string var id, code, name string
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrTierNotFound return nil, ErrTierNotFound
} }
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -1385,6 +1466,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
MessageLimit: messagesLimit.Int64, MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64, EmailLimit: emailsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64, ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
@ -1527,6 +1609,22 @@ func migrateFrom2(db *sql.DB) error {
return tx.Commit() return tx.Commit()
} }
func migrateFrom3(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString { func nullString(s string) sql.NullString {
if s == "" { if s == "" {
return sql.NullString{} return sql.NullString{}

View File

@ -893,6 +893,44 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
require.Nil(t, a.ResetTier("phil")) require.Nil(t, a.ResetTier("phil"))
} }
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
phoneNumbers, err := a.PhoneNumbers(phil.ID)
require.Nil(t, err)
require.Equal(t, 1, len(phoneNumbers))
require.Equal(t, "+1234567890", phoneNumbers[0])
require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890"))
phoneNumbers, err = a.PhoneNumbers(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(phoneNumbers))
// Paranoia check: We do NOT want to keep phone numbers in there
rows, err := a.db.Query(`SELECT * FROM user_phone`)
require.Nil(t, err)
require.False(t, rows.Next())
require.Nil(t, rows.Close())
}
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890"))
}
func TestSqliteCache_Migration_From1(t *testing.T) { func TestSqliteCache_Migration_From1(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db") filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)

View File

@ -86,6 +86,7 @@ type Tier struct {
MessageLimit int64 // Daily message limit MessageLimit int64 // Daily message limit
MessageExpiryDuration time.Duration // Cache duration for messages MessageExpiryDuration time.Duration // Cache duration for messages
EmailLimit int64 // Daily email limit EmailLimit int64 // Daily email limit
CallLimit int64 // Daily phone call limit
ReservationLimit int64 // Number of topic reservations allowed by user ReservationLimit int64 // Number of topic reservations allowed by user
AttachmentFileSizeLimit int64 // Max file size per file (bytes) AttachmentFileSizeLimit int64 // Max file size per file (bytes)
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
@ -131,6 +132,7 @@ type NotificationPrefs struct {
type Stats struct { type Stats struct {
Messages int64 Messages int64
Emails int64 Emails int64
Calls int64
} }
// Billing is a struct holding a user's billing information // Billing is a struct holding a user's billing information
@ -276,7 +278,10 @@ var (
ErrUnauthorized = errors.New("unauthorized") ErrUnauthorized = errors.New("unauthorized")
ErrInvalidArgument = errors.New("invalid argument") ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrTierNotFound = errors.New("tier not found") ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found") ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists")
) )

210
web/package-lock.json generated
View File

@ -3134,14 +3134,14 @@
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
}, },
"node_modules/@mui/base": { "node_modules/@mui/base": {
"version": "5.0.0-beta.0", "version": "5.0.0-beta.1",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.1.tgz",
"integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==", "integrity": "sha512-xrkDCeu3JQE+JjJUnJnOrdQJMXwKhbV4AW+FRjMIj5i9cHK3BAuatG/iqbf1M+jklVWLk0KdbgioKwK+03aYbA==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@emotion/is-prop-valid": "^1.2.0", "@emotion/is-prop-valid": "^1.2.0",
"@mui/types": "^7.2.4", "@mui/types": "^7.2.4",
"@mui/utils": "^5.12.3", "@mui/utils": "^5.13.1",
"@popperjs/core": "^2.11.7", "@popperjs/core": "^2.11.7",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@ -3166,9 +3166,9 @@
} }
}, },
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "5.13.0", "version": "5.13.1",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.1.tgz",
"integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==", "integrity": "sha512-qDHtNDO72NcBQMhaWBt9EZMvNiO+OXjPg5Sdk/6LgRDw6Zr3HdEZ5n2FJ/qtYsaT/okGyCuQavQkcZCOCEVf/g==",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/mui" "url": "https://opencollective.com/mui"
@ -3200,16 +3200,16 @@
} }
}, },
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "5.13.0", "version": "5.13.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz",
"integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==", "integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@mui/base": "5.0.0-beta.0", "@mui/base": "5.0.0-beta.1",
"@mui/core-downloads-tracker": "^5.13.0", "@mui/core-downloads-tracker": "^5.13.1",
"@mui/system": "^5.12.3", "@mui/system": "^5.13.1",
"@mui/types": "^7.2.4", "@mui/types": "^7.2.4",
"@mui/utils": "^5.12.3", "@mui/utils": "^5.13.1",
"@types/react-transition-group": "^4.4.6", "@types/react-transition-group": "^4.4.6",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"csstype": "^3.1.2", "csstype": "^3.1.2",
@ -3244,12 +3244,12 @@
} }
}, },
"node_modules/@mui/private-theming": { "node_modules/@mui/private-theming": {
"version": "5.12.3", "version": "5.13.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz",
"integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==", "integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@mui/utils": "^5.12.3", "@mui/utils": "^5.13.1",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -3301,15 +3301,15 @@
} }
}, },
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "5.12.3", "version": "5.13.1",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz",
"integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==", "integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@mui/private-theming": "^5.12.3", "@mui/private-theming": "^5.13.1",
"@mui/styled-engine": "^5.12.3", "@mui/styled-engine": "^5.12.3",
"@mui/types": "^7.2.4", "@mui/types": "^7.2.4",
"@mui/utils": "^5.12.3", "@mui/utils": "^5.13.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
@ -3353,13 +3353,13 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "5.12.3", "version": "5.13.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz",
"integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", "integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@types/prop-types": "^15.7.5", "@types/prop-types": "^15.7.5",
"@types/react-is": "^16.7.1 || ^17.0.0", "@types/react-is": "^18.2.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^18.2.0" "react-is": "^18.2.0"
}, },
@ -4016,9 +4016,9 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.1.4", "version": "20.1.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz",
"integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" "integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg=="
}, },
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
@ -4061,21 +4061,11 @@
} }
}, },
"node_modules/@types/react-is": { "node_modules/@types/react-is": {
"version": "17.0.4", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==", "integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==",
"dependencies": { "dependencies": {
"@types/react": "^17" "@types/react": "*"
}
},
"node_modules/@types/react-is/node_modules/@types/react": {
"version": "17.0.59",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.59.tgz",
"integrity": "sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
} }
}, },
"node_modules/@types/react-transition-group": { "node_modules/@types/react-transition-group": {
@ -4175,14 +4165,14 @@
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz",
"integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==", "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.4.0", "@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.59.5", "@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/type-utils": "5.59.5", "@typescript-eslint/type-utils": "5.59.6",
"@typescript-eslint/utils": "5.59.5", "@typescript-eslint/utils": "5.59.6",
"debug": "^4.3.4", "debug": "^4.3.4",
"grapheme-splitter": "^1.0.4", "grapheme-splitter": "^1.0.4",
"ignore": "^5.2.0", "ignore": "^5.2.0",
@ -4208,11 +4198,11 @@
} }
}, },
"node_modules/@typescript-eslint/experimental-utils": { "node_modules/@typescript-eslint/experimental-utils": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz",
"integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==", "integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==",
"dependencies": { "dependencies": {
"@typescript-eslint/utils": "5.59.5" "@typescript-eslint/utils": "5.59.6"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@ -4226,13 +4216,13 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz",
"integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==", "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "5.59.5", "@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/types": "5.59.5", "@typescript-eslint/types": "5.59.6",
"@typescript-eslint/typescript-estree": "5.59.5", "@typescript-eslint/typescript-estree": "5.59.6",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -4252,12 +4242,12 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz",
"integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==", "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.59.5", "@typescript-eslint/types": "5.59.6",
"@typescript-eslint/visitor-keys": "5.59.5" "@typescript-eslint/visitor-keys": "5.59.6"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@ -4268,12 +4258,12 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz",
"integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==", "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "5.59.5", "@typescript-eslint/typescript-estree": "5.59.6",
"@typescript-eslint/utils": "5.59.5", "@typescript-eslint/utils": "5.59.6",
"debug": "^4.3.4", "debug": "^4.3.4",
"tsutils": "^3.21.0" "tsutils": "^3.21.0"
}, },
@ -4294,9 +4284,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz",
"integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==", "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==",
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
@ -4306,12 +4296,12 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz",
"integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==", "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.59.5", "@typescript-eslint/types": "5.59.6",
"@typescript-eslint/visitor-keys": "5.59.5", "@typescript-eslint/visitor-keys": "5.59.6",
"debug": "^4.3.4", "debug": "^4.3.4",
"globby": "^11.1.0", "globby": "^11.1.0",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -4332,16 +4322,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz",
"integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==", "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9", "@types/json-schema": "^7.0.9",
"@types/semver": "^7.3.12", "@types/semver": "^7.3.12",
"@typescript-eslint/scope-manager": "5.59.5", "@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/types": "5.59.5", "@typescript-eslint/types": "5.59.6",
"@typescript-eslint/typescript-estree": "5.59.5", "@typescript-eslint/typescript-estree": "5.59.6",
"eslint-scope": "^5.1.1", "eslint-scope": "^5.1.1",
"semver": "^7.3.7" "semver": "^7.3.7"
}, },
@ -4377,11 +4367,11 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "5.59.5", "version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz",
"integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==", "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.59.5", "@typescript-eslint/types": "5.59.6",
"eslint-visitor-keys": "^3.3.0" "eslint-visitor-keys": "^3.3.0"
}, },
"engines": { "engines": {
@ -4956,9 +4946,9 @@
} }
}, },
"node_modules/axe-core": { "node_modules/axe-core": {
"version": "4.7.0", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz",
"integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==",
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@ -5511,9 +5501,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001487", "version": "1.0.30001488",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
"integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -6749,9 +6739,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.394", "version": "1.4.397",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz",
"integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==" "integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q=="
}, },
"node_modules/emittery": { "node_modules/emittery": {
"version": "0.8.1", "version": "0.8.1",
@ -9146,9 +9136,9 @@
} }
}, },
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.12.0", "version": "2.12.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz",
"integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==",
"dependencies": { "dependencies": {
"has": "^1.0.3" "has": "^1.0.3"
}, },
@ -13879,9 +13869,9 @@
} }
}, },
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "6.0.12", "version": "6.0.13",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
"integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==", "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@ -15976,9 +15966,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.17.3", "version": "5.17.4",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz",
"integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==",
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.2", "@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0", "acorn": "^8.5.0",
@ -16272,16 +16262,16 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.0.4", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true, "peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=12.20" "node": ">=4.2.0"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {

View File

@ -12,6 +12,8 @@ var config = {
enable_signup: true, enable_signup: true,
enable_payments: true, enable_payments: true,
enable_reservations: true, enable_reservations: true,
enable_emails: true,
enable_calls: true,
billing_contact: "", billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
}; };

View File

@ -152,7 +152,7 @@
"publish_dialog_chip_delay_label": "تأخير التسليم", "publish_dialog_chip_delay_label": "تأخير التسليم",
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
"subscribe_dialog_subscribe_button_cancel": "إلغاء", "subscribe_dialog_subscribe_button_cancel": "إلغاء",
"subscribe_dialog_login_button_back": "العودة", "common_back": "العودة",
"prefs_notifications_sound_play": "تشغيل الصوت المحدد", "prefs_notifications_sound_play": "تشغيل الصوت المحدد",
"prefs_notifications_min_priority_title": "أولوية دنيا", "prefs_notifications_min_priority_title": "أولوية دنيا",
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
@ -225,7 +225,7 @@
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
"account_tokens_table_current_session": "جلسة المتصفح الحالية", "account_tokens_table_current_session": "جلسة المتصفح الحالية",
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة", "common_copy_to_clipboard": "انسخ إلى الحافظة",
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية", "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول", "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",

View File

@ -104,7 +104,7 @@
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
"subscribe_dialog_login_username_label": "Потребител, напр. phil", "subscribe_dialog_login_username_label": "Потребител, напр. phil",
"subscribe_dialog_login_button_back": "Назад", "common_back": "Назад",
"subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_subscribe_button_cancel": "Отказ",
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
"subscribe_dialog_subscribe_button_subscribe": "Абониране", "subscribe_dialog_subscribe_button_subscribe": "Абониране",

View File

@ -91,7 +91,7 @@
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
"subscribe_dialog_login_password_label": "Heslo", "subscribe_dialog_login_password_label": "Heslo",
"subscribe_dialog_login_button_back": "Zpět", "common_back": "Zpět",
"subscribe_dialog_login_button_login": "Přihlásit se", "subscribe_dialog_login_button_login": "Přihlásit se",
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
"subscribe_dialog_error_user_anonymous": "anonymně", "subscribe_dialog_error_user_anonymous": "anonymně",
@ -305,7 +305,7 @@
"account_tokens_table_expires_header": "Vyprší", "account_tokens_table_expires_header": "Vyprší",
"account_tokens_table_never_expires": "Nikdy nevyprší", "account_tokens_table_never_expires": "Nikdy nevyprší",
"account_tokens_table_current_session": "Současná relace prohlížeče", "account_tokens_table_current_session": "Současná relace prohlížeče",
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky", "common_copy_to_clipboard": "Kopírování do schránky",
"account_tokens_table_label_header": "Popisek", "account_tokens_table_label_header": "Popisek",
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
"account_tokens_table_create_token_button": "Vytvořit přístupový token", "account_tokens_table_create_token_button": "Vytvořit přístupový token",

View File

@ -91,7 +91,7 @@
"publish_dialog_delay_label": "Forsinkelse", "publish_dialog_delay_label": "Forsinkelse",
"publish_dialog_button_send": "Send", "publish_dialog_button_send": "Send",
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld", "subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
"subscribe_dialog_login_button_back": "Tilbage", "common_back": "Tilbage",
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
"account_basics_title": "Konto", "account_basics_title": "Konto",
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
@ -209,7 +209,7 @@
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server", "subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
"account_basics_tier_upgrade_button": "Opgrader til Pro", "account_basics_tier_upgrade_button": "Opgrader til Pro",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
"account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder", "common_copy_to_clipboard": "Kopier til udklipsholder",
"prefs_reservations_edit_button": "Rediger emneadgang", "prefs_reservations_edit_button": "Rediger emneadgang",
"account_upgrade_dialog_title": "Skift kontoniveau", "account_upgrade_dialog_title": "Skift kontoniveau",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",

View File

@ -94,7 +94,7 @@
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
"prefs_appearance_title": "Darstellung", "prefs_appearance_title": "Darstellung",
"subscribe_dialog_login_password_label": "Kennwort", "subscribe_dialog_login_password_label": "Kennwort",
"subscribe_dialog_login_button_back": "Zurück", "common_back": "Zurück",
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen", "publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
"publish_dialog_chip_delay_label": "Auslieferung verzögern", "publish_dialog_chip_delay_label": "Auslieferung verzögern",
"publish_dialog_chip_topic_label": "Thema ändern", "publish_dialog_chip_topic_label": "Thema ändern",
@ -284,7 +284,7 @@
"account_tokens_table_expires_header": "Verfällt", "account_tokens_table_expires_header": "Verfällt",
"account_tokens_table_never_expires": "Verfällt nie", "account_tokens_table_never_expires": "Verfällt nie",
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung", "account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren", "common_copy_to_clipboard": "In die Zwischenablage kopieren",
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert", "account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
"account_tokens_table_create_token_button": "Access-Token erzeugen", "account_tokens_table_create_token_button": "Access-Token erzeugen",

View File

@ -2,6 +2,8 @@
"common_cancel": "Cancel", "common_cancel": "Cancel",
"common_save": "Save", "common_save": "Save",
"common_add": "Add", "common_add": "Add",
"common_back": "Back",
"common_copy_to_clipboard": "Copy to clipboard",
"signup_title": "Create a ntfy account", "signup_title": "Create a ntfy account",
"signup_form_username": "Username", "signup_form_username": "Username",
"signup_form_password": "Password", "signup_form_password": "Password",
@ -127,6 +129,9 @@
"publish_dialog_email_label": "Email", "publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
"publish_dialog_email_reset": "Remove email forward", "publish_dialog_email_reset": "Remove email forward",
"publish_dialog_call_label": "Phone call",
"publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444, or 'yes'",
"publish_dialog_call_reset": "Remove phone call",
"publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_label": "Attachment URL",
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Remove attachment URL", "publish_dialog_attach_reset": "Remove attachment URL",
@ -138,6 +143,7 @@
"publish_dialog_other_features": "Other features:", "publish_dialog_other_features": "Other features:",
"publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Forward to email", "publish_dialog_chip_email_label": "Forward to email",
"publish_dialog_chip_call_label": "Phone call",
"publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_url_label": "Attach file by URL",
"publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_attach_file_label": "Attach local file",
"publish_dialog_chip_delay_label": "Delay delivery", "publish_dialog_chip_delay_label": "Delay delivery",
@ -165,7 +171,6 @@
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
"subscribe_dialog_login_username_label": "Username, e.g. phil", "subscribe_dialog_login_username_label": "Username, e.g. phil",
"subscribe_dialog_login_password_label": "Password", "subscribe_dialog_login_password_label": "Password",
"subscribe_dialog_login_button_back": "Back",
"subscribe_dialog_login_button_login": "Login", "subscribe_dialog_login_button_login": "Login",
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved", "subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
@ -182,6 +187,21 @@
"account_basics_password_dialog_confirm_password_label": "Confirm password", "account_basics_password_dialog_confirm_password_label": "Confirm password",
"account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_button_submit": "Change password",
"account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_password_dialog_current_password_incorrect": "Password incorrect",
"account_basics_phone_numbers_title": "Phone numbers",
"account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.",
"account_basics_phone_numbers_description": "For phone call notifications",
"account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet",
"account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard",
"account_basics_phone_numbers_dialog_title": "Add phone number",
"account_basics_phone_numbers_dialog_number_label": "Phone number",
"account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Call me",
"account_basics_phone_numbers_dialog_code_label": "Verification code",
"account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call",
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
@ -203,6 +223,8 @@
"account_basics_tier_manage_billing_button": "Manage billing", "account_basics_tier_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_calls_title": "Phone calls made",
"account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account", "account_usage_reservations_none": "No reserved topics for this account",
"account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_title": "Attachment storage",
@ -232,6 +254,9 @@
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
"account_upgrade_dialog_tier_price_per_month": "month", "account_upgrade_dialog_tier_price_per_month": "month",
@ -254,7 +279,6 @@
"account_tokens_table_expires_header": "Expires", "account_tokens_table_expires_header": "Expires",
"account_tokens_table_never_expires": "Never expires", "account_tokens_table_never_expires": "Never expires",
"account_tokens_table_current_session": "Current browser session", "account_tokens_table_current_session": "Current browser session",
"account_tokens_table_copy_to_clipboard": "Copy to clipboard",
"account_tokens_table_copied_to_clipboard": "Access token copied", "account_tokens_table_copied_to_clipboard": "Access token copied",
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
"account_tokens_table_create_token_button": "Create access token", "account_tokens_table_create_token_button": "Create access token",

View File

@ -81,7 +81,7 @@
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.", "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil", "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
"subscribe_dialog_login_password_label": "Contraseña", "subscribe_dialog_login_password_label": "Contraseña",
"subscribe_dialog_login_button_back": "Volver", "common_back": "Volver",
"subscribe_dialog_login_button_login": "Iniciar sesión", "subscribe_dialog_login_button_login": "Iniciar sesión",
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
"subscribe_dialog_error_user_anonymous": "anónimo", "subscribe_dialog_error_user_anonymous": "anónimo",
@ -257,7 +257,7 @@
"account_tokens_table_expires_header": "Expira", "account_tokens_table_expires_header": "Expira",
"account_tokens_table_never_expires": "Nunca expira", "account_tokens_table_never_expires": "Nunca expira",
"account_tokens_table_current_session": "Sesión del navegador actual", "account_tokens_table_current_session": "Sesión del navegador actual",
"account_tokens_table_copy_to_clipboard": "Copiar al portapapeles", "common_copy_to_clipboard": "Copiar al portapapeles",
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado", "account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual", "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
"account_tokens_table_create_token_button": "Crear token de acceso", "account_tokens_table_create_token_button": "Crear token de acceso",

View File

@ -106,7 +106,7 @@
"prefs_notifications_title": "Notifications", "prefs_notifications_title": "Notifications",
"prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications",
"prefs_users_add_button": "Ajouter un utilisateur", "prefs_users_add_button": "Ajouter un utilisateur",
"subscribe_dialog_login_button_back": "Retour", "common_back": "Retour",
"subscribe_dialog_error_user_anonymous": "anonyme", "subscribe_dialog_error_user_anonymous": "anonyme",
"prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_sound_no_sound": "Aucun son",
"prefs_notifications_min_priority_title": "Priorité minimum", "prefs_notifications_min_priority_title": "Priorité minimum",
@ -293,7 +293,7 @@
"account_tokens_table_expires_header": "Expire", "account_tokens_table_expires_header": "Expire",
"account_tokens_table_never_expires": "N'expire jamais", "account_tokens_table_never_expires": "N'expire jamais",
"account_tokens_table_current_session": "Session de navigation actuelle", "account_tokens_table_current_session": "Session de navigation actuelle",
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier", "common_copy_to_clipboard": "Copier dans le presse-papier",
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié", "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
"account_tokens_table_create_token_button": "Créer un jeton d'accès", "account_tokens_table_create_token_button": "Créer un jeton d'accès",
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",

View File

@ -84,7 +84,7 @@
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
"subscribe_dialog_login_password_label": "Jelszó", "subscribe_dialog_login_password_label": "Jelszó",
"subscribe_dialog_login_button_back": "Vissza", "common_back": "Vissza",
"subscribe_dialog_login_button_login": "Belépés", "subscribe_dialog_login_button_login": "Belépés",
"subscribe_dialog_error_user_anonymous": "névtelen", "subscribe_dialog_error_user_anonymous": "névtelen",
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",

View File

@ -116,7 +116,7 @@
"common_save": "Simpan", "common_save": "Simpan",
"prefs_appearance_title": "Tampilan", "prefs_appearance_title": "Tampilan",
"subscribe_dialog_login_password_label": "Kata sandi", "subscribe_dialog_login_password_label": "Kata sandi",
"subscribe_dialog_login_button_back": "Kembali", "common_back": "Kembali",
"prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_sound_title": "Suara notifikasi",
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi", "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
@ -278,7 +278,7 @@
"account_tokens_table_expires_header": "Kedaluwarsa", "account_tokens_table_expires_header": "Kedaluwarsa",
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa", "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
"account_tokens_table_current_session": "Sesi peramban saat ini", "account_tokens_table_current_session": "Sesi peramban saat ini",
"account_tokens_table_copy_to_clipboard": "Salin ke papan klip", "common_copy_to_clipboard": "Salin ke papan klip",
"account_tokens_table_copied_to_clipboard": "Token akses disalin", "account_tokens_table_copied_to_clipboard": "Token akses disalin",
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
"account_tokens_table_create_token_button": "Buat token akses", "account_tokens_table_create_token_button": "Buat token akses",

View File

@ -178,7 +178,7 @@
"prefs_notifications_sound_play": "Riproduci il suono selezionato", "prefs_notifications_sound_play": "Riproduci il suono selezionato",
"prefs_notifications_min_priority_title": "Priorità minima", "prefs_notifications_min_priority_title": "Priorità minima",
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.", "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
"subscribe_dialog_login_button_back": "Indietro", "common_back": "Indietro",
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
"prefs_notifications_title": "Notifiche", "prefs_notifications_title": "Notifiche",
"prefs_notifications_delete_after_title": "Elimina le notifiche", "prefs_notifications_delete_after_title": "Elimina le notifiche",

View File

@ -20,7 +20,7 @@
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
"subscribe_dialog_login_password_label": "パスワード", "subscribe_dialog_login_password_label": "パスワード",
"subscribe_dialog_login_button_back": "戻る", "common_back": "戻る",
"subscribe_dialog_login_button_login": "ログイン", "subscribe_dialog_login_button_login": "ログイン",
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
"prefs_notifications_min_priority_max_only": "優先度最高のみ", "prefs_notifications_min_priority_max_only": "優先度最高のみ",
@ -258,7 +258,7 @@
"account_tokens_table_expires_header": "期限", "account_tokens_table_expires_header": "期限",
"account_tokens_table_never_expires": "無期限", "account_tokens_table_never_expires": "無期限",
"account_tokens_table_current_session": "現在のブラウザセッション", "account_tokens_table_current_session": "現在のブラウザセッション",
"account_tokens_table_copy_to_clipboard": "クリップボードにコピー", "common_copy_to_clipboard": "クリップボードにコピー",
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
"account_tokens_table_create_token_button": "アクセストークンを生成", "account_tokens_table_create_token_button": "アクセストークンを生成",

View File

@ -93,7 +93,7 @@
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
"subscribe_dialog_login_password_label": "비밀번호", "subscribe_dialog_login_password_label": "비밀번호",
"subscribe_dialog_login_button_back": "뒤로가기", "common_back": "뒤로가기",
"subscribe_dialog_login_button_login": "로그인", "subscribe_dialog_login_button_login": "로그인",
"prefs_notifications_title": "알림", "prefs_notifications_title": "알림",
"prefs_notifications_sound_title": "알림 효과음", "prefs_notifications_sound_title": "알림 효과음",

View File

@ -113,7 +113,7 @@
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke", "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned", "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
"priority_min": "min.", "priority_min": "min.",
"subscribe_dialog_login_button_back": "Tilbake", "common_back": "Tilbake",
"prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_notifications_delete_after_three_hours": "Etter tre timer",
"prefs_users_table_base_url_header": "Tjeneste-nettadresse", "prefs_users_table_base_url_header": "Tjeneste-nettadresse",
"common_cancel": "Avbryt", "common_cancel": "Avbryt",

View File

@ -140,7 +140,7 @@
"subscribe_dialog_subscribe_title": "Onderwerp abonneren", "subscribe_dialog_subscribe_title": "Onderwerp abonneren",
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
"subscribe_dialog_login_password_label": "Wachtwoord", "subscribe_dialog_login_password_label": "Wachtwoord",
"subscribe_dialog_login_button_back": "Terug", "common_back": "Terug",
"subscribe_dialog_login_button_login": "Aanmelden", "subscribe_dialog_login_button_login": "Aanmelden",
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
"subscribe_dialog_error_user_anonymous": "anoniem", "subscribe_dialog_error_user_anonymous": "anoniem",
@ -331,7 +331,7 @@
"account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
"account_tokens_table_last_access_header": "Laatste toegang", "account_tokens_table_last_access_header": "Laatste toegang",
"account_tokens_table_expires_header": "Verloopt op", "account_tokens_table_expires_header": "Verloopt op",
"account_tokens_table_copy_to_clipboard": "Kopieer naar klembord", "common_copy_to_clipboard": "Kopieer naar klembord",
"account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd", "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
"account_tokens_delete_dialog_submit_button": "Token definitief verwijderen", "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
"prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",

View File

@ -107,7 +107,7 @@
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
"subscribe_dialog_login_password_label": "Hasło", "subscribe_dialog_login_password_label": "Hasło",
"publish_dialog_button_cancel": "Anuluj", "publish_dialog_button_cancel": "Anuluj",
"subscribe_dialog_login_button_back": "Powrót", "common_back": "Powrót",
"subscribe_dialog_login_button_login": "Zaloguj się", "subscribe_dialog_login_button_login": "Zaloguj się",
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
"subscribe_dialog_error_user_anonymous": "anonim", "subscribe_dialog_error_user_anonymous": "anonim",
@ -253,7 +253,7 @@
"account_tokens_table_expires_header": "Termin ważności", "account_tokens_table_expires_header": "Termin ważności",
"account_tokens_table_never_expires": "Bezterminowy", "account_tokens_table_never_expires": "Bezterminowy",
"account_tokens_table_current_session": "Aktualna sesja przeglądarki", "account_tokens_table_current_session": "Aktualna sesja przeglądarki",
"account_tokens_table_copy_to_clipboard": "Kopiuj do schowka", "common_copy_to_clipboard": "Kopiuj do schowka",
"account_tokens_table_copied_to_clipboard": "Token został skopiowany", "account_tokens_table_copied_to_clipboard": "Token został skopiowany",
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
"account_tokens_table_create_token_button": "Utwórz token dostępowy", "account_tokens_table_create_token_button": "Utwórz token dostępowy",

View File

@ -144,7 +144,7 @@
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.", "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"", "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
"subscribe_dialog_login_password_label": "Palavra-passe", "subscribe_dialog_login_password_label": "Palavra-passe",
"subscribe_dialog_login_button_back": "Voltar", "common_back": "Voltar",
"subscribe_dialog_login_button_login": "Autenticar", "subscribe_dialog_login_button_login": "Autenticar",
"subscribe_dialog_error_user_anonymous": "anónimo", "subscribe_dialog_error_user_anonymous": "anónimo",
"prefs_notifications_title": "Notificações", "prefs_notifications_title": "Notificações",

View File

@ -93,7 +93,7 @@
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
"subscribe_dialog_login_password_label": "Senha", "subscribe_dialog_login_password_label": "Senha",
"subscribe_dialog_login_button_back": "Voltar", "common_back": "Voltar",
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima", "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
"prefs_notifications_delete_after_title": "Apagar notificações", "prefs_notifications_delete_after_title": "Apagar notificações",

View File

@ -98,7 +98,7 @@
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
"subscribe_dialog_login_password_label": "Пароль", "subscribe_dialog_login_password_label": "Пароль",
"subscribe_dialog_login_button_back": "Назад", "common_back": "Назад",
"subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_login_button_login": "Войти",
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
"subscribe_dialog_error_user_anonymous": "анонимный пользователь", "subscribe_dialog_error_user_anonymous": "анонимный пользователь",
@ -206,7 +206,7 @@
"account_basics_tier_free": "Бесплатный", "account_basics_tier_free": "Бесплатный",
"account_tokens_dialog_title_create": "Создать токен доступа", "account_tokens_dialog_title_create": "Создать токен доступа",
"account_tokens_dialog_title_delete": "Удалить токен доступа", "account_tokens_dialog_title_delete": "Удалить токен доступа",
"account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена", "common_copy_to_clipboard": "Скопировать в буфер обмена",
"account_tokens_dialog_button_cancel": "Отмена", "account_tokens_dialog_button_cancel": "Отмена",
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",

View File

@ -95,14 +95,14 @@
"publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com", "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
"publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .", "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
"publish_dialog_button_send": "Skicka", "publish_dialog_button_send": "Skicka",
"subscribe_dialog_login_button_back": "Tillbaka", "common_back": "Tillbaka",
"account_basics_tier_free": "Gratis", "account_basics_tier_free": "Gratis",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
"account_delete_title": "Ta bort konto", "account_delete_title": "Ta bort konto",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
"account_upgrade_dialog_button_cancel": "Avbryt", "account_upgrade_dialog_button_cancel": "Avbryt",
"account_tokens_table_copy_to_clipboard": "Kopiera till urklipp", "common_copy_to_clipboard": "Kopiera till urklipp",
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat", "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.", "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
"account_tokens_table_create_token_button": "Skapa åtkomsttoken", "account_tokens_table_create_token_button": "Skapa åtkomsttoken",

View File

@ -34,7 +34,7 @@
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.", "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
"subscribe_dialog_login_password_label": "Parola", "subscribe_dialog_login_password_label": "Parola",
"subscribe_dialog_login_button_back": "Geri", "common_back": "Geri",
"subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_login_button_login": "Oturum aç",
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
"subscribe_dialog_error_user_anonymous": "anonim", "subscribe_dialog_error_user_anonymous": "anonim",
@ -268,7 +268,7 @@
"account_tokens_table_token_header": "Belirteç", "account_tokens_table_token_header": "Belirteç",
"account_tokens_table_label_header": "Etiket", "account_tokens_table_label_header": "Etiket",
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu", "account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
"account_tokens_table_copy_to_clipboard": "Panoya kopyala", "common_copy_to_clipboard": "Panoya kopyala",
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı", "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
"account_tokens_table_create_token_button": "Erişim belirteci oluştur", "account_tokens_table_create_token_button": "Erişim belirteci oluştur",

View File

@ -53,7 +53,7 @@
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
"subscribe_dialog_subscribe_base_url_label": "URL служби", "subscribe_dialog_subscribe_base_url_label": "URL служби",
"subscribe_dialog_login_password_label": "Пароль", "subscribe_dialog_login_password_label": "Пароль",
"subscribe_dialog_login_button_back": "Назад", "common_back": "Назад",
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",

View File

@ -103,7 +103,7 @@
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
"subscribe_dialog_login_username_label": "用户名,例如 phil", "subscribe_dialog_login_username_label": "用户名,例如 phil",
"subscribe_dialog_login_password_label": "密码", "subscribe_dialog_login_password_label": "密码",
"subscribe_dialog_login_button_back": "返回", "common_back": "返回",
"subscribe_dialog_login_button_login": "登录", "subscribe_dialog_login_button_login": "登录",
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
"subscribe_dialog_error_user_anonymous": "匿名", "subscribe_dialog_error_user_anonymous": "匿名",
@ -333,7 +333,7 @@
"account_tokens_table_expires_header": "过期", "account_tokens_table_expires_header": "过期",
"account_tokens_table_never_expires": "永不过期", "account_tokens_table_never_expires": "永不过期",
"account_tokens_table_current_session": "当前浏览器会话", "account_tokens_table_current_session": "当前浏览器会话",
"account_tokens_table_copy_to_clipboard": "复制到剪贴板", "common_copy_to_clipboard": "复制到剪贴板",
"account_tokens_table_copied_to_clipboard": "已复制访问令牌", "account_tokens_table_copied_to_clipboard": "已复制访问令牌",
"account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
"account_tokens_table_create_token_button": "创建访问令牌", "account_tokens_table_create_token_button": "创建访问令牌",

View File

@ -70,7 +70,7 @@
"subscribe_dialog_subscribe_button_subscribe": "訂閱", "subscribe_dialog_subscribe_button_subscribe": "訂閱",
"emoji_picker_search_clear": "清除", "emoji_picker_search_clear": "清除",
"subscribe_dialog_login_password_label": "密碼", "subscribe_dialog_login_password_label": "密碼",
"subscribe_dialog_login_button_back": "返回", "common_back": "返回",
"subscribe_dialog_login_button_login": "登入", "subscribe_dialog_login_button_login": "登入",
"prefs_notifications_delete_after_never": "從不", "prefs_notifications_delete_after_never": "從不",
"prefs_users_add_button": "新增使用者", "prefs_users_add_button": "新增使用者",

View File

@ -2,13 +2,15 @@ import {
accountBillingPortalUrl, accountBillingPortalUrl,
accountBillingSubscriptionUrl, accountBillingSubscriptionUrl,
accountPasswordUrl, accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
accountReservationSingleUrl, accountReservationSingleUrl,
accountReservationUrl, accountReservationUrl,
accountSettingsUrl, accountSettingsUrl,
accountSubscriptionSingleUrl,
accountSubscriptionUrl, accountSubscriptionUrl,
accountTokenUrl, accountTokenUrl,
accountUrl, maybeWithBearerAuth, accountUrl,
maybeWithBearerAuth,
tiersUrl, tiersUrl,
withBasicAuth, withBasicAuth,
withBearerAuth withBearerAuth
@ -18,7 +20,7 @@ import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next"; import i18n from "i18next";
import prefs from "./Prefs"; import prefs from "./Prefs";
import routes from "../components/routes"; import routes from "../components/routes";
import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; import {fetchOrThrow, UnauthorizedError} from "./errors";
const delayMillis = 45000; // 45 seconds const delayMillis = 45000; // 45 seconds
const intervalMillis = 900000; // 15 minutes const intervalMillis = 900000; // 15 minutes
@ -299,6 +301,44 @@ class AccountApi {
return await response.json(); // May throw SyntaxError return await response.json(); // May throw SyntaxError
} }
async verifyPhoneNumber(phoneNumber, channel) {
const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel
})
});
}
async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code
})
});
}
async deletePhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber
})
});
}
async sync() { async sync() {
try { try {
if (!session.token()) { if (!session.token()) {

View File

@ -27,6 +27,8 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
@ -206,10 +208,12 @@ export const formatBytes = (bytes, decimals = 2) => {
} }
export const formatNumber = (n) => { export const formatNumber = (n) => {
if (n % 1000 === 0) { if (n === 0) {
return n;
} else if (n % 1000 === 0) {
return `${n/1000}k`; return `${n/1000}k`;
} }
return n; return n.toLocaleString();
} }
export const formatPrice = (n) => { export const formatPrice = (n) => {

View File

@ -4,10 +4,14 @@ import {
Alert, Alert,
CardActions, CardActions,
CardContent, CardContent,
Chip,
FormControl, FormControl,
FormControlLabel,
LinearProgress, LinearProgress,
Link, Link,
Portal, Portal,
Radio,
RadioGroup,
Select, Select,
Snackbar, Snackbar,
Stack, Stack,
@ -51,6 +55,8 @@ import {ContentCopy, Public} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import DialogContentText from "@mui/material/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
import {ProChip} from "./SubscriptionPopup";
import AddIcon from "@mui/icons-material/Add";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -79,6 +85,7 @@ const Basics = () => {
<PrefGroup> <PrefGroup>
<Username/> <Username/>
<ChangePassword/> <ChangePassword/>
<PhoneNumbers/>
<AccountType/> <AccountType/>
</PrefGroup> </PrefGroup>
</Card> </Card>
@ -319,6 +326,206 @@ const AccountType = () => {
) )
}; };
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const labelId = "prefPhoneNumbers";
const handleDialogOpen = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
};
const handleCopy = (phoneNumber) => {
navigator.clipboard.writeText(phoneNumber);
setSnackOpen(true);
};
const handleDelete = async (phoneNumber) => {
try {
await accountApi.deletePhoneNumber(phoneNumber);
} catch (e) {
console.log(`[Account] Error deleting phone number`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
};
if (!config.enable_calls) {
return null;
}
if (account?.limits.calls === 0) {
return (
<Pref title={<>{t("account_basics_phone_numbers_title")}{config.enable_payments && <ProChip/>}</>} description={t("account_basics_phone_numbers_description")}>
<em>{t("account_usage_calls_none")}</em>
</Pref>
)
}
return (
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
<div aria-labelledby={labelId}>
{account?.phone_numbers?.map(phoneNumber =>
<Chip
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{phoneNumber}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(phoneNumber)}
onDelete={() => handleDelete(phoneNumber)}
/>
)}
{!account?.phone_numbers &&
<em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
}
<IconButton onClick={handleDialogOpen}><AddIcon/></IconButton>
</div>
<AddPhoneNumberDialog
key={`addPhoneNumberDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_phone_numbers_copied_to_clipboard")}
/>
</Portal>
</Pref>
)
};
const AddPhoneNumberDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [channel, setChannel] = useState("sms");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyPhone();
} else {
await checkVerifyPhone();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
const verifyPhone = async () => {
try {
setSending(true);
await accountApi.verifyPhoneNumber(phoneNumber, channel);
setVerificationCodeSent(true);
} catch (e) {
console.log(`[Account] Error sending verification`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const checkVerifyPhone = async () => {
try {
setSending(true);
await accountApi.addPhoneNumber(phoneNumber, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming verification`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("account_basics_phone_numbers_dialog_description")}
</DialogContentText>
{!verificationCodeSent &&
<div style={{display: "flex"}}>
<TextField
margin="dense"
label={t("account_basics_phone_numbers_dialog_number_label")}
aria-label={t("account_basics_phone_numbers_dialog_number_label")}
placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
type="tel"
value={phoneNumber}
onChange={ev => setPhoneNumber(ev.target.value)}
inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }}
variant="standard"
sx={{ flexGrow: 1 }}
/>
<FormControl sx={{ flexWrap: "nowrap" }}>
<RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
<FormControlLabel value="sms" control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} />
<FormControlLabel value="call" control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} />
</RadioGroup>
</FormControl>
</div>
}
{verificationCodeSent &&
<TextField
margin="dense"
label={t("account_basics_phone_numbers_dialog_code_label")}
aria-label={t("account_basics_phone_numbers_dialog_code_label")}
placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
type="text"
value={code}
onChange={ev => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
variant="standard"
/>
}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
{!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
{!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
{verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
</Button>
</DialogFooter>
</Dialog>
);
};
const Stats = () => { const Stats = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
@ -337,23 +544,18 @@ const Stats = () => {
{t("account_usage_title")} {t("account_usage_title")}
</Typography> </Typography>
<PrefGroup> <PrefGroup>
<Pref title={t("account_usage_reservations_title")}> {(account.role === Role.ADMIN || account.limits.reservations > 0) &&
{(account.role === Role.ADMIN || account.limits.reservations > 0) && <Pref title={t("account_usage_reservations_title")}>
<> <div>
<div> <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography> </div>
</div> <LinearProgress
<LinearProgress variant="determinate"
variant="determinate" value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} />
/> </Pref>
</> }
}
{account.role === Role.USER && account.limits.reservations === 0 &&
<em>{t("account_usage_reservations_none")}</em>
}
</Pref>
<Pref title={ <Pref title={
<> <>
{t("account_usage_messages_title")} {t("account_usage_messages_title")}
@ -361,29 +563,48 @@ const Stats = () => {
</> </>
}> }>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100} value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
/> />
</Pref> </Pref>
<Pref title={ {config.enable_emails &&
<> <Pref title={
{t("account_usage_emails_title")} <>
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip> {t("account_usage_emails_title")}
</> <Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
}> </>
<div> }>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> <div>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails.toLocaleString()}</Typography>
</div> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
<LinearProgress </div>
variant="determinate" <LinearProgress
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100} variant="determinate"
/> value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
</Pref> />
</Pref>
}
{config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) &&
<Pref title={
<>
{t("account_usage_calls_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.calls.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
/>
</Pref>
}
<Pref <Pref
alignTop alignTop
title={t("account_usage_attachment_storage_title")} title={t("account_usage_attachment_storage_title")}
@ -404,6 +625,16 @@ const Stats = () => {
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
/> />
</Pref> </Pref>
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 &&
<Pref title={<>{t("account_usage_reservations_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_reservations_none")}</em>
</Pref>
}
{config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
<Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_calls_none")}</em>
</Pref>
}
</PrefGroup> </PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && {account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
<Typography variant="body1"> <Typography variant="body1">
@ -535,7 +766,7 @@ const TokensTable = (props) => {
<span> <span>
<span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span> <span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span>
... ...
<Tooltip title={t("account_tokens_table_copy_to_clipboard")} placement="right"> <Tooltip title={t("common_copy_to_clipboard")} placement="right">
<IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton> <IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
</Tooltip> </Tooltip>
</span> </span>

View File

@ -45,6 +45,7 @@ const PublishDialog = (props) => {
const [filename, setFilename] = useState(""); const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false); const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [call, setCall] = useState("");
const [delay, setDelay] = useState(""); const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false); const [publishAnother, setPublishAnother] = useState(false);
@ -52,6 +53,7 @@ const PublishDialog = (props) => {
const [showClickUrl, setShowClickUrl] = useState(false); const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
const [showCall, setShowCall] = useState(false);
const [showDelay, setShowDelay] = useState(false); const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl; const showAttachFile = !!attachFile && !showAttachUrl;
@ -124,6 +126,9 @@ const PublishDialog = (props) => {
if (email.trim()) { if (email.trim()) {
url.searchParams.append("email", email.trim()); url.searchParams.append("email", email.trim());
} }
if (call.trim()) {
url.searchParams.append("call", call.trim());
}
if (delay.trim()) { if (delay.trim()) {
url.searchParams.append("delay", delay.trim()); url.searchParams.append("delay", delay.trim());
} }
@ -406,6 +411,27 @@ const PublishDialog = (props) => {
/> />
</ClosableRow> </ClosableRow>
} }
{showCall &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
setCall("");
setShowCall(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_call_label")}
placeholder={t("publish_dialog_call_placeholder")}
value={call}
onChange={ev => setCall(ev.target.value)}
disabled={disabled}
type="tel"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_call_label")
}}
/>
</ClosableRow>
}
{showAttachUrl && {showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
setAttachUrl(""); setAttachUrl("");
@ -510,6 +536,7 @@ const PublishDialog = (props) => {
<div> <div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}

View File

@ -288,7 +288,7 @@ const LoginPage = (props) => {
/> />
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button> <Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button> <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -277,14 +277,14 @@ const LimitReachedChip = () => {
); );
}; };
const ProChip = () => { export const ProChip = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Chip <Chip
label={"ntfy Pro"} label={"ntfy Pro"}
variant="outlined" variant="outlined"
color="primary" color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }} sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/> />
); );
}; };

View File

@ -298,11 +298,12 @@ const TierCard = (props) => {
</div> </div>
<List dense> <List dense>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>} {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature> {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
</List> </List>
{tier.prices && props.interval === SubscriptionInterval.MONTH && {tier.prices && props.interval === SubscriptionInterval.MONTH &&
<Typography variant="body2" color="gray"> <Typography variant="body2" color="gray">