diff --git a/cmd/serve.go b/cmd/serve.go index 912e295a..4e123e93 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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-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: "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: "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"}), @@ -151,6 +155,10 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") 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") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") 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") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { 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 @@ -308,6 +318,10 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix + conf.TwilioAccount = twilioAccount + conf.TwilioAuthToken = twilioAuthToken + conf.TwilioFromNumber = twilioFromNumber + conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit diff --git a/cmd/tier.go b/cmd/tier.go index c0b83d71..f1c8ddcb 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,6 +18,7 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 + defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" @@ -48,6 +49,7 @@ var cmdTier = &cli.Command{ &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.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.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"}, @@ -91,6 +93,7 @@ Examples: &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, &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: "call-limit", Usage: "daily phone call 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-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"), MessageExpiryDuration: messageExpiryDuration, EmailLimit: c.Int64("email-limit"), + CallLimit: c.Int64("call-limit"), ReservationLimit: c.Int64("reservation-limit"), AttachmentFileSizeLimit: attachmentFileSizeLimit, AttachmentTotalSizeLimit: attachmentTotalSizeLimit, @@ -267,6 +271,9 @@ func execTierChange(c *cli.Context) error { if c.IsSet("email-limit") { tier.EmailLimit = c.Int64("email-limit") } + if c.IsSet("call-limit") { + tier.CallLimit = c.Int64("call-limit") + } if c.IsSet("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 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, "- Phone call limit: %d\n", tier.CallLimit) 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 total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) diff --git a/docs/config.md b/docs/config.md index fa599388..df77e9a7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -814,6 +814,7 @@ ntfy tier add \ --message-limit=10000 \ --message-expiry-duration=24h \ --email-limit=50 \ + --call-limit=10 \ --reservation-limit=10 \ --attachment-file-size-limit=100M \ --attachment-total-size-limit=1G \ @@ -854,6 +855,22 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" 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 !!! info Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. diff --git a/docs/publish.md b/docs/publish.md index c38ff0d1..1b5957b9 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2695,6 +2695,133 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
+## 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. + +
+ ![phone number verification](static/img/web-phone-verify.png) +
Phone number verification in the web app
+
+ +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 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 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. @@ -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, but just in case, let's list them all: -| Limit | Description | -|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **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. | -| **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. | -| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **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 expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | -| **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. | +| Limit | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **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. | +| **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. | +| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **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 expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **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 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-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-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `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 | diff --git a/docs/releases.md b/docs/releases.md index 440b950a..17debf07 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1182,6 +1182,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **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) **Bug fixes + maintenance:** diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 new file mode 100644 index 00000000..0cace65f Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.mp3 differ diff --git a/docs/static/audio/ntfy-phone-call.ogg b/docs/static/audio/ntfy-phone-call.ogg new file mode 100644 index 00000000..cbbf6b60 Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.ogg differ diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png new file mode 100644 index 00000000..335aeef1 Binary files /dev/null and b/docs/static/img/web-phone-verify.png differ diff --git a/go.mod b/go.mod index 1f4c9e75..162fd943 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( require ( 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/iam v1.0.1 // indirect cloud.google.com/go/longrunning v0.4.2 // indirect diff --git a/go.sum b/go.sum index ccb1ae4d..bfaf339d 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,15 @@ 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.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/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= -cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= -cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= -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 v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= 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/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= 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/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/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= 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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 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/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= 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-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.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/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 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-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.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/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-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/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= 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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= 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/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/log/event.go b/log/event.go index ccde4126..b4b8f59f 100644 --- a/log/event.go +++ b/log/event.go @@ -41,34 +41,34 @@ func newEvent() *Event { // Fatal logs the event as FATAL, and exits the program with exit code 1 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 os.Exit(1) } // Error logs the event with log level error -func (e *Event) Error(message string, v ...any) { - e.maybeLog(ErrorLevel, message, v...) +func (e *Event) Error(message string, v ...any) *Event { + return e.Log(ErrorLevel, message, v...) } // Warn logs the event with log level warn -func (e *Event) Warn(message string, v ...any) { - e.maybeLog(WarnLevel, message, v...) +func (e *Event) Warn(message string, v ...any) *Event { + return e.Log(WarnLevel, message, v...) } // Info logs the event with log level info -func (e *Event) Info(message string, v ...any) { - e.maybeLog(InfoLevel, message, v...) +func (e *Event) Info(message string, v ...any) *Event { + return e.Log(InfoLevel, message, v...) } // Debug logs the event with log level debug -func (e *Event) Debug(message string, v ...any) { - e.maybeLog(DebugLevel, message, v...) +func (e *Event) Debug(message string, v ...any) *Event { + return e.Log(DebugLevel, message, v...) } // Trace logs the event with log level trace -func (e *Event) Trace(message string, v ...any) { - e.maybeLog(TraceLevel, message, v...) +func (e *Event) Trace(message string, v ...any) *Event { + return e.Log(TraceLevel, message, v...) } // Tag adds a "tag" field to the log event @@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event { 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 func (e *Event) Fields(fields Context) *Event { 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. func (e *Event) Render(l Level, message string, v ...any) string { appliedContexters := e.maybeApplyContexters() - if !e.shouldLog(l) { + if !e.Loggable(l) { return "" } e.Message = fmt.Sprintf(message, v...) @@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string { return e.String() } -// maybeLog 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) { +// Log logs the event to the defined output, or does nothing if Render returns an empty string +func (e *Event) Log(l Level, message string, v ...any) *Event { if m := e.Render(l, message, v...); m != "" { log.Println(m) } + return e } // 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, ", ")) } -func (e *Event) shouldLog(l Level) bool { - return e.globalLevelWithOverride() <= l -} - func (e *Event) globalLevelWithOverride() Level { mu.RLock() l, ov := level, overrides diff --git a/log/log_test.go b/log/log_test.go index ed35b495..d7ceb1c9 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -198,6 +198,30 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) { 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) { t.Cleanup(resetState) diff --git a/server/config.go b/server/config.go index 59da448a..376862a1 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,12 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioAccount string + TwilioAuthToken string + TwilioFromNumber string + TwilioCallsBaseURL string + TwilioVerifyBaseURL string + TwilioVerifyService string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -183,6 +189,12 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", + TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests + TwilioAccount: "", + TwilioAuthToken: "", + TwilioFromNumber: "", + TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests + TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, diff --git a/server/errors.go b/server/errors.go index 7812989b..eee916b5 100644 --- a/server/errors.go +++ b/server/errors.go @@ -108,12 +108,20 @@ var ( errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", 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} + 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} 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} 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} 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} 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} @@ -126,6 +134,7 @@ var ( 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} 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} 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} diff --git a/server/log.go b/server/log.go index 643f2ccb..c638ed97 100644 --- a/server/log.go +++ b/server/log.go @@ -20,6 +20,7 @@ const ( tagFirebase = "firebase" tagSMTP = "smtp" // Receive email tagEmail = "email" // Send email + tagTwilio = "twilio" tagFileCache = "file_cache" tagMessageCache = "message_cache" tagStripe = "stripe" diff --git a/server/server.go b/server/server.go index 2e8d20fd..7e8ea251 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,8 @@ var ( apiAccountSettingsPath = "/v1/account/settings" apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" + apiAccountPhonePath = "/v1/account/phone" + apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -100,6 +102,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site 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) } 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! + } 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 { return s.handleStats(w, r, v) } 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, EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", + EnableCalls: s.config.TwilioAccount != "", + EnableEmails: s.config.SMTPSenderFrom != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, @@ -683,7 +694,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } 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 { 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) } else if email != "" && !vrate.EmailAllowed() { 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 != "" { 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_unifiedpush": unifiedpush, "message_email": email, + "message_call": call, }) if ev.IsTrace() { 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 != "" { go s.sendEmail(v, m, email) } + if s.config.TwilioAccount != "" && call != "" { + go s.callPhone(v, r, m, call) + } if s.config.UpstreamBaseURL != "" { 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") firebase = readBoolParam(r, true, "x-firebase", "firebase") 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 !urlRegex.MatchString(attach) { - return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -880,13 +903,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") 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") if messageStr != "" { @@ -895,7 +924,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") 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") if delayStr != "" { if !cache { - return false, false, "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", false, errHTTPBadRequestDelayNoCache } 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()) if err != nil { - return false, false, "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", false, errHTTPBadRequestDelayCannotParse } 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() { - return false, false, "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -923,7 +955,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) 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! @@ -937,7 +969,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false 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. diff --git a/server/server.yml b/server/server.yml index 204005ca..74841137 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,6 +144,18 @@ # smtp-server-domain: # 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 # intermediaries closing the connection for inactivity. # diff --git a/server/server_account.go b/server/server_account.go index 73f0792b..6e6a6864 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -56,6 +56,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Messages: limits.MessageLimit, MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), Emails: limits.EmailLimit, + Calls: limits.CallLimit, Reservations: limits.ReservationsLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit, AttachmentFileSize: limits.AttachmentFileSizeLimit, @@ -67,6 +68,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis MessagesRemaining: stats.MessagesRemaining, Emails: stats.Emails, EmailsRemaining: stats.EmailsRemaining, + Calls: stats.Calls, + CallsRemaining: stats.CallsRemaining, Reservations: stats.Reservations, ReservationsRemaining: stats.ReservationsRemaining, AttachmentTotalSize: stats.AttachmentTotalSize, @@ -105,17 +108,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), } } - reservations, err := s.userManager.Reservations(u.Name) - if err != nil { - return err - } - if len(reservations) > 0 { - response.Reservations = make([]*apiAccountReservation, 0) - for _, r := range reservations { - response.Reservations = append(response.Reservations, &apiAccountReservation{ - Topic: r.Topic, - Everyone: r.Everyone.String(), - }) + if s.config.EnableReservations { + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.Topic, + Everyone: r.Everyone.String(), + }) + } } } 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 { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -511,6 +525,72 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi 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 func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_account_test.go b/server/server_account_test.go index 465e4be1..119efb16 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,6 +151,8 @@ func TestAccount_Get_Anonymous(t *testing.T) { require.Equal(t, int64(1004), account.Stats.MessagesRemaining) require.Equal(t, int64(0), account.Stats.Emails) 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) require.Equal(t, 200, rr.Code) @@ -498,6 +500,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.EnableSignup = true + conf.EnableReservations = true + conf.TwilioAccount = "dummy" s := newTestServer(t, conf) // Create user @@ -510,6 +514,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { MessageLimit: 123, MessageExpiryDuration: 86400 * time.Second, EmailLimit: 32, + CallLimit: 10, ReservationLimit: 2, AttachmentFileSizeLimit: 1231231, 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(86400), account.Limits.MessagesExpiryDuration) 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(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) diff --git a/server/server_metrics.go b/server/server_metrics.go index d3f17929..88fa9f15 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,6 +15,8 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter + metricCallsMadeSuccess prometheus.Counter + metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter metricMatrixPublishedSuccess prometheus.Counter metricMatrixPublishedFailure prometheus.Counter @@ -57,6 +59,12 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ 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{ Name: "ntfy_unifiedpush_published_success", }) @@ -95,6 +103,8 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, + metricCallsMadeSuccess, + metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, metricMatrixPublishedSuccess, metricMatrixPublishedFailure, diff --git a/server/server_middleware.go b/server/server_middleware.go index e0435bb2..7aea45a3 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -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 { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/server_payments.go b/server/server_payments.go index cb585966..1e98d059 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,6 +68,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, + Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, @@ -96,6 +97,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, + Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit, diff --git a/server/server_test.go b/server/server_test.go index adf77a73..57251413 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1190,7 +1190,20 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { "E-Mail": "test@example.com", "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) { diff --git a/server/server_twilio.go b/server/server_twilio.go new file mode 100644 index 00000000..06723574 --- /dev/null +++ b/server/server_twilio.go @@ -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 = ` + + + + You have a message from notify on topic %s. Message: + + %s + + End of message. + + 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. + + + Goodbye. +` +) + +// 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() +} diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go new file mode 100644 index 00000000..1b710130 --- /dev/null +++ b/server/server_twilio_test.go @@ -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) +} diff --git a/server/types.go b/server/types.go index 962f7dba..4280f6c9 100644 --- a/server/types.go +++ b/server/types.go @@ -311,6 +311,16 @@ type apiAccountTokenResponse struct { 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 { Code string `json:"code"` Name string `json:"name"` @@ -321,6 +331,7 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` + Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentFileSize int64 `json:"attachment_file_size"` @@ -333,6 +344,8 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` + Calls int64 `json:"calls"` + CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` ReservationsRemaining int64 `json:"reservations_remaining"` AttachmentTotalSize int64 `json:"attachment_total_size"` @@ -362,6 +375,7 @@ type apiAccountResponse struct { Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []string `json:"phone_numbers,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` @@ -379,6 +393,8 @@ type apiConfigResponse struct { EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` + EnableCalls bool `json:"enable_calls"` + EnableEmails bool `json:"enable_emails"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/server/util.go b/server/util.go index f0b49d28..a3a45547 100644 --- a/server/util.go +++ b/server/util.go @@ -18,6 +18,14 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { if value == "" { 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" } diff --git a/server/visitor.go b/server/visitor.go index 63a3ac60..e4c06f66 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,6 +24,10 @@ const ( // 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 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 @@ -56,6 +60,7 @@ type visitor struct { requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) messagesLimiter *util.FixedLimiter // Rate limiter for messages emailsLimiter *util.RateLimiter // Rate limiter for emails + callsLimiter *util.FixedLimiter // Rate limiter for calls subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil @@ -79,6 +84,7 @@ type visitorLimits struct { EmailLimit int64 EmailLimitBurst int EmailLimitReplenish rate.Limit + CallLimit int64 ReservationsLimit int64 AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -91,6 +97,8 @@ type visitorStats struct { MessagesRemaining int64 Emails int64 EmailsRemaining int64 + Calls int64 + CallsRemaining int64 Reservations int64 ReservationsRemaining int64 AttachmentTotalSize int64 @@ -107,10 +115,11 @@ const ( ) 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 { messages = user.Stats.Messages emails = user.Stats.Emails + calls = user.Stats.Calls } v := &visitor{ config: conf, @@ -124,11 +133,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana requestLimiter: nil, // Set in resetLimiters messagesLimiter: nil, // Set in resetLimiters, may be nil emailsLimiter: nil, // Set in resetLimiters + callsLimiter: nil, // Set in resetLimiters, may be nil bandwidthLimiter: nil, // Set in resetLimiters accountLimiter: 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 } @@ -147,12 +157,19 @@ func (v *visitor) contextNoLock() log.Context { "visitor_messages": info.Stats.Messages, "visitor_messages_limit": info.Limits.MessageLimit, "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_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 { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() @@ -216,6 +233,12 @@ func (v *visitor) EmailAllowed() bool { 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 { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() @@ -296,6 +319,7 @@ func (v *visitor) Stats() *user.Stats { return &user.Stats{ Messages: v.messagesLimiter.Value(), Emails: v.emailsLimiter.Value(), + Calls: v.callsLimiter.Value(), } } @@ -304,6 +328,7 @@ func (v *visitor) ResetStats() { defer v.mu.RUnlock() v.emailsLimiter.Reset() v.messagesLimiter.Reset() + v.callsLimiter.Reset() } // 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 v.user = u // u may be nil! if shouldResetLimiters { - var messages, emails int64 + var messages, emails, calls int64 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 "" } -func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) { +func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) { limits := v.limitsNoLock() v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) + v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { 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{ Messages: messages, Emails: emails, + Calls: calls, }) } 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, EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), + CallLimit: tier.CallLimit, ReservationsLimit: tier.ReservationLimit, AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, @@ -420,6 +448,7 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), + CallLimit: visitorDefaultCallsLimit, ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, @@ -465,12 +494,15 @@ func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) infoLightNoLock() *visitorInfo { messages := v.messagesLimiter.Value() emails := v.emailsLimiter.Value() + calls := v.callsLimiter.Value() limits := v.limitsNoLock() stats := &visitorStats{ Messages: messages, MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), Emails: emails, EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), + Calls: calls, + CallsRemaining: zeroIfNegative(limits.CallLimit - calls), } return &visitorInfo{ Limits: limits, diff --git a/user/manager.go b/user/manager.go index 08c36db1..00407ab3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -6,7 +6,7 @@ import ( "encoding/json" "errors" "fmt" - _ "github.com/mattn/go-sqlite3" // SQLite driver + "github.com/mattn/go-sqlite3" "github.com/stripe/stripe-go/v74" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/log" @@ -55,6 +55,7 @@ const ( messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, + calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL, @@ -76,6 +77,7 @@ const ( sync_topic TEXT NOT NULL, stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), stripe_customer_id TEXT, stripe_subscription_id TEXT, stripe_subscription_status TEXT, @@ -109,6 +111,12 @@ const ( PRIMARY KEY (user_id, token), 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 ( id INT PRIMARY KEY, version INT NOT NULL @@ -123,26 +131,26 @@ const ( ` 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 LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` 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 LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` 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 JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` 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 LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -173,8 +181,8 @@ const ( updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` 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 = ` - 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` updateTierQuery = ` 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 = ? ` 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 ` 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 WHERE code = ? ` 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 WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) ` @@ -298,7 +310,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 3 + currentSchemaVersion = 4 insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` updateSchemaVersion = `UPDATE schemaVersion SET version = ? 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_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 ( migrations = map[int]func(db *sql.DB) error{ 1: migrateFrom1, 2: migrateFrom2, + 3: migrateFrom3, } ) @@ -623,6 +648,56 @@ func (a *Manager) RemoveExpiredTokens() error { 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 func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { @@ -705,9 +780,10 @@ func (a *Manager) writeUserStatsQueue() error { "user_id": userID, "messages_count": update.Messages, "emails_count": update.Emails, + "calls_count": update.Calls, }). 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 } } @@ -789,6 +865,9 @@ func (a *Manager) AddUser(username, password string, role Role) error { userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() 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 nil @@ -916,12 +995,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var messages, emails, calls int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { 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 } else if err := rows.Err(); err != nil { return nil, err @@ -936,6 +1015,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { Stats: &Stats{ Messages: messages, Emails: emails, + Calls: calls, }, Billing: &Billing{ StripeCustomerID: stripeCustomerID.String, // May be empty @@ -959,6 +1039,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, @@ -1291,7 +1372,7 @@ func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { 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 nil @@ -1299,7 +1380,7 @@ func (a *Manager) AddTier(tier *Tier) error { // UpdateTier updates a tier's properties in the database 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 nil @@ -1368,11 +1449,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string 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() { 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 } else if err := rows.Err(); err != nil { return nil, err @@ -1385,6 +1466,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, @@ -1527,6 +1609,22 @@ func migrateFrom2(db *sql.DB) error { 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 { if s == "" { return sql.NullString{} diff --git a/user/manager_test.go b/user/manager_test.go index cd2e1032..5e01f497 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -893,6 +893,44 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) { 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) { filename := filepath.Join(t.TempDir(), "user.db") db, err := sql.Open("sqlite3", filename) diff --git a/user/types.go b/user/types.go index 2486f110..11895785 100644 --- a/user/types.go +++ b/user/types.go @@ -86,6 +86,7 @@ type Tier struct { MessageLimit int64 // Daily message limit MessageExpiryDuration time.Duration // Cache duration for messages EmailLimit int64 // Daily email limit + CallLimit int64 // Daily phone call limit ReservationLimit int64 // Number of topic reservations allowed by user AttachmentFileSizeLimit int64 // Max file size per file (bytes) AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) @@ -131,6 +132,7 @@ type NotificationPrefs struct { type Stats struct { Messages int64 Emails int64 + Calls int64 } // Billing is a struct holding a user's billing information @@ -276,7 +278,10 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user already exists") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") + ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") + ErrPhoneNumberExists = errors.New("phone number already exists") ) diff --git a/web/package-lock.json b/web/package-lock.json index f1b4785f..0d2670ff 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3134,14 +3134,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-beta.0", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz", - "integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==", + "version": "5.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.1.tgz", + "integrity": "sha512-xrkDCeu3JQE+JjJUnJnOrdQJMXwKhbV4AW+FRjMIj5i9cHK3BAuatG/iqbf1M+jklVWLk0KdbgioKwK+03aYbA==", "dependencies": { "@babel/runtime": "^7.21.0", "@emotion/is-prop-valid": "^1.2.0", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@popperjs/core": "^2.11.7", "clsx": "^1.2.1", "prop-types": "^15.8.1", @@ -3166,9 +3166,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz", - "integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.1.tgz", + "integrity": "sha512-qDHtNDO72NcBQMhaWBt9EZMvNiO+OXjPg5Sdk/6LgRDw6Zr3HdEZ5n2FJ/qtYsaT/okGyCuQavQkcZCOCEVf/g==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui" @@ -3200,16 +3200,16 @@ } }, "node_modules/@mui/material": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz", - "integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz", + "integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/base": "5.0.0-beta.0", - "@mui/core-downloads-tracker": "^5.13.0", - "@mui/system": "^5.12.3", + "@mui/base": "5.0.0-beta.1", + "@mui/core-downloads-tracker": "^5.13.1", + "@mui/system": "^5.13.1", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@types/react-transition-group": "^4.4.6", "clsx": "^1.2.1", "csstype": "^3.1.2", @@ -3244,12 +3244,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz", - "integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz", + "integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "prop-types": "^15.8.1" }, "engines": { @@ -3301,15 +3301,15 @@ } }, "node_modules/@mui/system": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz", - "integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz", + "integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/private-theming": "^5.12.3", + "@mui/private-theming": "^5.13.1", "@mui/styled-engine": "^5.12.3", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "clsx": "^1.2.1", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -3353,13 +3353,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", - "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz", + "integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==", "dependencies": { "@babel/runtime": "^7.21.0", "@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", "react-is": "^18.2.0" }, @@ -4016,9 +4016,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", - "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" + "version": "20.1.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz", + "integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4061,21 +4061,11 @@ } }, "node_modules/@types/react-is": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz", - "integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==", "dependencies": { - "@types/react": "^17" - } - }, - "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" + "@types/react": "*" } }, "node_modules/@types/react-transition-group": { @@ -4175,14 +4165,14 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz", - "integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", + "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/type-utils": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/type-utils": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -4208,11 +4198,11 @@ } }, "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz", - "integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz", + "integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==", "dependencies": { - "@typescript-eslint/utils": "5.59.5" + "@typescript-eslint/utils": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4226,13 +4216,13 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz", - "integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", + "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", "dependencies": { - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "debug": "^4.3.4" }, "engines": { @@ -4252,12 +4242,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz", - "integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", + "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5" + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4268,12 +4258,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz", - "integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", + "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", "dependencies": { - "@typescript-eslint/typescript-estree": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/typescript-estree": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -4294,9 +4284,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz", - "integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", + "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4306,12 +4296,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz", - "integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", + "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -4332,16 +4322,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz", - "integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", + "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -4377,11 +4367,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz", - "integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", + "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", "dependencies": { - "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/types": "5.59.6", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -4956,9 +4946,9 @@ } }, "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", + "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", "engines": { "node": ">=4" } @@ -5511,9 +5501,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001487", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", - "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", "funding": [ { "type": "opencollective", @@ -6749,9 +6739,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.394", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", - "integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==" + "version": "1.4.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz", + "integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q==" }, "node_modules/emittery": { "version": "0.8.1", @@ -9146,9 +9136,9 @@ } }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", + "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", "dependencies": { "has": "^1.0.3" }, @@ -13879,9 +13869,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz", - "integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15976,9 +15966,9 @@ } }, "node_modules/terser": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", - "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "version": "5.17.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz", + "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==", "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", @@ -16272,16 +16262,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/web/public/config.js b/web/public/config.js index 30da6913..89bbed9f 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -12,6 +12,8 @@ var config = { enable_signup: true, enable_payments: true, enable_reservations: true, + enable_emails: true, + enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] }; diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json index a3919ffd..0c9fcc7d 100644 --- a/web/public/static/langs/ar.json +++ b/web/public/static/langs/ar.json @@ -152,7 +152,7 @@ "publish_dialog_chip_delay_label": "تأخير التسليم", "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", "subscribe_dialog_subscribe_button_cancel": "إلغاء", - "subscribe_dialog_login_button_back": "العودة", + "common_back": "العودة", "prefs_notifications_sound_play": "تشغيل الصوت المحدد", "prefs_notifications_min_priority_title": "أولوية دنيا", "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", @@ -225,7 +225,7 @@ "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", "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_create_token_button": "إنشاء رمز مميز للوصول", "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index 8178c469..a040b015 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -104,7 +104,7 @@ "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_login_username_label": "Потребител, напр. phil", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_subscribe_button_subscribe": "Абониране", diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index f8826584..aeff195b 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -91,7 +91,7 @@ "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "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_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_anonymous": "anonymně", @@ -305,7 +305,7 @@ "account_tokens_table_expires_header": "Vyprší", "account_tokens_table_never_expires": "Nikdy nevyprší", "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_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", "account_tokens_table_create_token_button": "Vytvořit přístupový token", diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json index d60c56c2..c7477dfc 100644 --- a/web/public/static/langs/da.json +++ b/web/public/static/langs/da.json @@ -91,7 +91,7 @@ "publish_dialog_delay_label": "Forsinkelse", "publish_dialog_button_send": "Send", "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", - "subscribe_dialog_login_button_back": "Tilbage", + "common_back": "Tilbage", "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", "account_basics_title": "Konto", "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", @@ -209,7 +209,7 @@ "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", "account_basics_tier_upgrade_button": "Opgrader til Pro", "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", "account_upgrade_dialog_title": "Skift kontoniveau", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 88a5c14a..e3f55922 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -94,7 +94,7 @@ "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "prefs_appearance_title": "Darstellung", "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_delay_label": "Auslieferung verzögern", "publish_dialog_chip_topic_label": "Thema ändern", @@ -284,7 +284,7 @@ "account_tokens_table_expires_header": "Verfällt", "account_tokens_table_never_expires": "Verfällt nie", "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_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", "account_tokens_table_create_token_button": "Access-Token erzeugen", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 8760eb31..04233b79 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -2,6 +2,8 @@ "common_cancel": "Cancel", "common_save": "Save", "common_add": "Add", + "common_back": "Back", + "common_copy_to_clipboard": "Copy to clipboard", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", @@ -127,6 +129,9 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "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_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -138,6 +143,7 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "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_file_label": "Attach local file", "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_username_label": "Username, e.g. phil", "subscribe_dialog_login_password_label": "Password", - "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "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_button_submit": "Change password", "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_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", @@ -203,6 +223,8 @@ "account_basics_tier_manage_billing_button": "Manage billing", "account_usage_messages_title": "Published messages", "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_none": "No reserved topics for this account", "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_emails_one": "{{emails}} daily email", "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_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", @@ -254,7 +279,6 @@ "account_tokens_table_expires_header": "Expires", "account_tokens_table_never_expires": "Never expires", "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_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_create_token_button": "Create access token", diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 0fc7c3a4..3166a522 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -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_username_label": "Nombre de usuario, ej. phil", "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_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_anonymous": "anónimo", @@ -257,7 +257,7 @@ "account_tokens_table_expires_header": "Expira", "account_tokens_table_never_expires": "Nunca expira", "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_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", diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index a24ece08..ba71eb4a 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -106,7 +106,7 @@ "prefs_notifications_title": "Notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_users_add_button": "Ajouter un utilisateur", - "subscribe_dialog_login_button_back": "Retour", + "common_back": "Retour", "subscribe_dialog_error_user_anonymous": "anonyme", "prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_min_priority_title": "Priorité minimum", @@ -293,7 +293,7 @@ "account_tokens_table_expires_header": "Expire", "account_tokens_table_never_expires": "N'expire jamais", "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_create_token_button": "Créer un jeton d'accès", "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index 975d8d97..b52e3a48 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -84,7 +84,7 @@ "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_password_label": "Jelszó", - "subscribe_dialog_login_button_back": "Vissza", + "common_back": "Vissza", "subscribe_dialog_login_button_login": "Belépés", "subscribe_dialog_error_user_anonymous": "névtelen", "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 027653bd..51e6a98a 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -116,7 +116,7 @@ "common_save": "Simpan", "prefs_appearance_title": "Tampilan", "subscribe_dialog_login_password_label": "Kata sandi", - "subscribe_dialog_login_button_back": "Kembali", + "common_back": "Kembali", "prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah 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_never_expires": "Tidak pernah kedaluwarsa", "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_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", "account_tokens_table_create_token_button": "Buat token akses", diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index 87ea04a4..a62d31fe 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -178,7 +178,7 @@ "prefs_notifications_sound_play": "Riproduci il suono selezionato", "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_button_back": "Indietro", + "common_back": "Indietro", "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", "prefs_notifications_title": "Notifiche", "prefs_notifications_delete_after_title": "Elimina le notifiche", diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 1b24ec0d..7eb1c7d4 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -20,7 +20,7 @@ "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_password_label": "パスワード", - "subscribe_dialog_login_button_back": "戻る", + "common_back": "戻る", "subscribe_dialog_login_button_login": "ログイン", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_max_only": "優先度最高のみ", @@ -258,7 +258,7 @@ "account_tokens_table_expires_header": "期限", "account_tokens_table_never_expires": "無期限", "account_tokens_table_current_session": "現在のブラウザセッション", - "account_tokens_table_copy_to_clipboard": "クリップボードにコピー", + "common_copy_to_clipboard": "クリップボードにコピー", "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", "account_tokens_table_create_token_button": "アクセストークンを生成", diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json index 67c31280..2e46c7a1 100644 --- a/web/public/static/langs/ko.json +++ b/web/public/static/langs/ko.json @@ -93,7 +93,7 @@ "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", "subscribe_dialog_login_password_label": "비밀번호", - "subscribe_dialog_login_button_back": "뒤로가기", + "common_back": "뒤로가기", "subscribe_dialog_login_button_login": "로그인", "prefs_notifications_title": "알림", "prefs_notifications_sound_title": "알림 효과음", diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json index 312791da..0dd9571b 100644 --- a/web/public/static/langs/nb_NO.json +++ b/web/public/static/langs/nb_NO.json @@ -113,7 +113,7 @@ "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", "priority_min": "min.", - "subscribe_dialog_login_button_back": "Tilbake", + "common_back": "Tilbake", "prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_users_table_base_url_header": "Tjeneste-nettadresse", "common_cancel": "Avbryt", diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index b9ac8e17..ca7a2a13 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -140,7 +140,7 @@ "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_login_password_label": "Wachtwoord", - "subscribe_dialog_login_button_back": "Terug", + "common_back": "Terug", "subscribe_dialog_login_button_login": "Aanmelden", "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", "subscribe_dialog_error_user_anonymous": "anoniem", @@ -331,7 +331,7 @@ "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", "account_tokens_table_last_access_header": "Laatste toegang", "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_delete_dialog_submit_button": "Token definitief verwijderen", "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json index 5e6bcbe5..9dea2b8a 100644 --- a/web/public/static/langs/pl.json +++ b/web/public/static/langs/pl.json @@ -107,7 +107,7 @@ "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", "subscribe_dialog_login_password_label": "Hasło", "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_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", "subscribe_dialog_error_user_anonymous": "anonim", @@ -253,7 +253,7 @@ "account_tokens_table_expires_header": "Termin ważności", "account_tokens_table_never_expires": "Bezterminowy", "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_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", "account_tokens_table_create_token_button": "Utwórz token dostępowy", diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 196baf4f..bf753c9a 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -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_username_label": "Nome, por exemplo: \"filipe\"", "subscribe_dialog_login_password_label": "Palavra-passe", - "subscribe_dialog_login_button_back": "Voltar", + "common_back": "Voltar", "subscribe_dialog_login_button_login": "Autenticar", "subscribe_dialog_error_user_anonymous": "anónimo", "prefs_notifications_title": "Notificações", diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 79622be3..acf5bca0 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -93,7 +93,7 @@ "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", "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_max_only": "Apenas prioridade máxima", "prefs_notifications_delete_after_title": "Apagar notificações", diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 42025e43..9633d97d 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -98,7 +98,7 @@ "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", "subscribe_dialog_error_user_anonymous": "анонимный пользователь", @@ -206,7 +206,7 @@ "account_basics_tier_free": "Бесплатный", "account_tokens_dialog_title_create": "Создать токен доступа", "account_tokens_dialog_title_delete": "Удалить токен доступа", - "account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена", + "common_copy_to_clipboard": "Скопировать в буфер обмена", "account_tokens_dialog_button_cancel": "Отмена", "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index 9e9dfc20..31e809c5 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -95,14 +95,14 @@ "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 dokumentationen .", "publish_dialog_button_send": "Skicka", - "subscribe_dialog_login_button_back": "Tillbaka", + "common_back": "Tillbaka", "account_basics_tier_free": "Gratis", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", "account_delete_title": "Ta bort konto", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", "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_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 dokumentationen.", "account_tokens_table_create_token_button": "Skapa åtkomsttoken", diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index 8bdb88d3..3eccda88 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -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_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_password_label": "Parola", - "subscribe_dialog_login_button_back": "Geri", + "common_back": "Geri", "subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_anonymous": "anonim", @@ -268,7 +268,7 @@ "account_tokens_table_token_header": "Belirteç", "account_tokens_table_label_header": "Etiket", "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_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", "account_tokens_table_create_token_button": "Erişim belirteci oluştur", diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 686a3d3e..8683769e 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -53,7 +53,7 @@ "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", "subscribe_dialog_subscribe_base_url_label": "URL служби", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index 4da4328c..2db95f56 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -103,7 +103,7 @@ "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", "subscribe_dialog_login_username_label": "用户名,例如 phil", "subscribe_dialog_login_password_label": "密码", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登录", "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", "subscribe_dialog_error_user_anonymous": "匿名", @@ -333,7 +333,7 @@ "account_tokens_table_expires_header": "过期", "account_tokens_table_never_expires": "永不过期", "account_tokens_table_current_session": "当前浏览器会话", - "account_tokens_table_copy_to_clipboard": "复制到剪贴板", + "common_copy_to_clipboard": "复制到剪贴板", "account_tokens_table_copied_to_clipboard": "已复制访问令牌", "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", "account_tokens_table_create_token_button": "创建访问令牌", diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index c1b4de81..aafc28e0 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -70,7 +70,7 @@ "subscribe_dialog_subscribe_button_subscribe": "訂閱", "emoji_picker_search_clear": "清除", "subscribe_dialog_login_password_label": "密碼", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登入", "prefs_notifications_delete_after_never": "從不", "prefs_users_add_button": "新增使用者", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 243286b4..915e3bb8 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -2,13 +2,15 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, accountPasswordUrl, + accountPhoneUrl, + accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, - accountSubscriptionSingleUrl, accountSubscriptionUrl, accountTokenUrl, - accountUrl, maybeWithBearerAuth, + accountUrl, + maybeWithBearerAuth, tiersUrl, withBasicAuth, withBearerAuth @@ -18,7 +20,7 @@ import subscriptionManager from "./SubscriptionManager"; import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; -import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; +import {fetchOrThrow, UnauthorizedError} from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes @@ -299,6 +301,44 @@ class AccountApi { 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() { try { if (!session.token()) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6eb4ac54..346df37f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -27,6 +27,8 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; 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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; @@ -206,10 +208,12 @@ export const formatBytes = (bytes, decimals = 2) => { } export const formatNumber = (n) => { - if (n % 1000 === 0) { + if (n === 0) { + return n; + } else if (n % 1000 === 0) { return `${n/1000}k`; } - return n; + return n.toLocaleString(); } export const formatPrice = (n) => { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 0e37aa3e..710510d2 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -4,10 +4,14 @@ import { Alert, CardActions, CardContent, + Chip, FormControl, + FormControlLabel, LinearProgress, Link, Portal, + Radio, + RadioGroup, Select, Snackbar, Stack, @@ -51,6 +55,8 @@ import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; +import {ProChip} from "./SubscriptionPopup"; +import AddIcon from "@mui/icons-material/Add"; const Account = () => { if (!session.exists()) { @@ -79,6 +85,7 @@ const Basics = () => { + @@ -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 ( + {t("account_basics_phone_numbers_title")}{config.enable_payments && }} description={t("account_basics_phone_numbers_description")}> + {t("account_usage_calls_none")} + + ) + } + + return ( + +
+ {account?.phone_numbers?.map(phoneNumber => + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> + )} + {!account?.phone_numbers && + {t("account_basics_phone_numbers_no_phone_numbers_yet")} + } + +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> + +
+ ) +}; + +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 ( + + {t("account_basics_phone_numbers_dialog_title")} + + + {t("account_basics_phone_numbers_dialog_description")} + + {!verificationCodeSent && +
+ setPhoneNumber(ev.target.value)} + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + sx={{ flexGrow: 1 }} + /> + + + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} /> + + +
+ } + {verificationCodeSent && + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} + variant="standard" + /> + } +
+ + + + +
+ ); +}; + + const Stats = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); @@ -337,23 +544,18 @@ const Stats = () => { {t("account_usage_title")} - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && - <> -
- {account.stats.reservations} - {account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - /> - - } - {account.role === Role.USER && account.limits.reservations === 0 && - {t("account_usage_reservations_none")} - } -
+ {(account.role === Role.ADMIN || account.limits.reservations > 0) && + +
+ {account.stats.reservations.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + /> +
+ } {t("account_usage_messages_title")} @@ -361,29 +563,48 @@ const Stats = () => { }>
- {account.stats.messages} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} + {account.stats.messages.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}
- - {t("account_usage_emails_title")} - - - }> -
- {account.stats.emails} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")} -
- -
+ {config.enable_emails && + + {t("account_usage_emails_title")} + + + }> +
+ {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} +
+ +
+ } + {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && + + {t("account_usage_calls_title")} + + + }> +
+ {account.stats.calls.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + /> +
+ } { value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> + {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && + {t("account_usage_reservations_title")}{config.enable_payments && }}> + {t("account_usage_reservations_none")} + + } + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && + {t("account_usage_calls_title")}{config.enable_payments && }}> + {t("account_usage_calls_none")} + + }
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && @@ -535,7 +766,7 @@ const TokensTable = (props) => { {token.token.slice(0, 12)} ... - + handleCopy(token.token)}> diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index bdf6fb62..0353abe7 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,6 +45,7 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); + const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -52,6 +53,7 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); + const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); const showAttachFile = !!attachFile && !showAttachUrl; @@ -124,6 +126,9 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } if (delay.trim()) { url.searchParams.append("delay", delay.trim()); } @@ -406,6 +411,27 @@ const PublishDialog = (props) => { /> } + {showCall && + { + setCall(""); + setShowCall(false); + }}> + setCall(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_call_label") + }} + /> + + } {showAttachUrl && { setAttachUrl(""); @@ -510,6 +536,7 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 4fd4f8c4..95f1c473 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -288,7 +288,7 @@ const LoginPage = (props) => { /> - + diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 7655605d..024b6f23 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -277,14 +277,14 @@ const LimitReachedChip = () => { ); }; -const ProChip = () => { +export const ProChip = () => { const { t } = useTranslation(); return ( ); }; diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c62560a3..0b91b1b1 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -298,11 +298,12 @@ const TierCard = (props) => {
{tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} + {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH &&