mirror of
https://github.com/binwiederhier/ntfy.git
synced 2024-12-25 18:01:51 +03:00
commit
b154ce5b0c
14
cmd/serve.go
14
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
|
||||
|
@ -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))
|
||||
|
@ -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.
|
||||
|
151
docs/publish.md
151
docs/publish.md
@ -2695,6 +2695,133 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
|
||||
<figcaption>Publishing a message via e-mail</figcaption>
|
||||
</figure>
|
||||
|
||||
## Phone calls
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
You can use ntfy to call a phone and **read the message out loud using text-to-speech**.
|
||||
Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have
|
||||
the ntfy app installed on their phone.
|
||||
|
||||
**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is
|
||||
**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone
|
||||
number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`.
|
||||
You may also simply pass `yes` as a value to pick the first of your verified phone numbers.
|
||||
On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
|
||||
|
||||
<figure markdown>
|
||||
![phone number verification](static/img/web-phone-verify.png)
|
||||
<figcaption>Phone number verification in the <a href="https://ntfy.sh/account">web app</a></figcaption>
|
||||
</figure>
|
||||
|
||||
As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll
|
||||
be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues).
|
||||
|
||||
!!! info
|
||||
You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**.
|
||||
This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or
|
||||
violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated.
|
||||
|
||||
Here's how you use it:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||
-H "Call: +12223334444" \
|
||||
-d "Your garage seems to be on fire. You should probably check that out." \
|
||||
ntfy.sh/alerts
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||
--call=+12223334444 \
|
||||
alerts "Your garage seems to be on fire. You should probably check that out."
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /alerts HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
Call: +12223334444
|
||||
|
||||
Your garage seems to be on fire. You should probably check that out.
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/alerts', {
|
||||
method: 'POST',
|
||||
body: "Your garage seems to be on fire. You should probably check that out.",
|
||||
headers: {
|
||||
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
|
||||
'Call': '+12223334444'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
|
||||
strings.NewReader("Your garage seems to be on fire. You should probably check that out."))
|
||||
req.Header.Set("Call", "+12223334444")
|
||||
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/alerts"
|
||||
Headers = @{
|
||||
Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
|
||||
Call = "+12223334444"
|
||||
}
|
||||
Body = "Your garage seems to be on fire. You should probably check that out."
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/alerts",
|
||||
data="Your garage seems to be on fire. You should probably check that out.",
|
||||
headers={
|
||||
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2",
|
||||
"Call": "+12223334444"
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" .
|
||||
"Call: +12223334444",
|
||||
'content' => 'Your garage seems to be on fire. You should probably check that out.'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
Here's what a phone call from ntfy sounds like:
|
||||
|
||||
<audio controls>
|
||||
<source src="../static/audio/ntfy-phone-call.mp3" type="audio/mpeg">
|
||||
<source src="../static/audio/ntfy-phone-call.ogg" type="audio/ogg">
|
||||
</audio>
|
||||
|
||||
Audio transcript:
|
||||
|
||||
> You have a notification from ntfy on topic alerts.
|
||||
> Message: Your garage seems to be on fire. You should probably check that out. End message.
|
||||
> This message was sent by user phil. It will be repeated up to three times.
|
||||
|
||||
## Authentication
|
||||
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 |
|
||||
|
@ -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:**
|
||||
|
BIN
docs/static/audio/ntfy-phone-call.mp3
vendored
Normal file
BIN
docs/static/audio/ntfy-phone-call.mp3
vendored
Normal file
Binary file not shown.
BIN
docs/static/audio/ntfy-phone-call.ogg
vendored
Normal file
BIN
docs/static/audio/ntfy-phone-call.ogg
vendored
Normal file
Binary file not shown.
BIN
docs/static/img/web-phone-verify.png
vendored
Normal file
BIN
docs/static/img/web-phone-verify.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
2
go.mod
2
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
|
||||
|
22
go.sum
22
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=
|
||||
|
41
log/event.go
41
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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
#
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
173
server/server_twilio.go
Normal file
173
server/server_twilio.go
Normal file
@ -0,0 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioCallFormat = `
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic %s. Message:
|
||||
<break time="1s"/>
|
||||
%s
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user %s. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`
|
||||
)
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
// If the user is anonymous, it will return an error.
|
||||
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousCallsNotAllowed
|
||||
}
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(phoneNumbers) == 0 {
|
||||
return "", errHTTPBadRequestPhoneNumberNotVerified
|
||||
}
|
||||
if toBool(phoneNumber) {
|
||||
return phoneNumbers[0], nil
|
||||
} else if util.Contains(phoneNumbers, phoneNumber) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
for _, p := range phoneNumbers {
|
||||
if p == phoneNumber {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
}
|
||||
return "", errHTTPBadRequestPhoneNumberNotVerified
|
||||
}
|
||||
|
||||
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
|
||||
// Failures will be logged, but not returned to the caller.
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioFromNumber)
|
||||
data.Set("To", to)
|
||||
data.Set("Twiml", body)
|
||||
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
|
||||
response, err := s.callPhoneInternal(data)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
||||
minc(metricCallsMadeSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Channel", channel)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
||||
return err
|
||||
}
|
||||
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Code", code)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
if ev.IsTrace() {
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.Field("twilio_response", string(response))
|
||||
}
|
||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return errHTTPGonePhoneVerificationExpired
|
||||
}
|
||||
return errHTTPInternalError
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio phone verification response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlEscapeText(text string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(text))
|
||||
return buf.String()
|
||||
}
|
264
server/server_twilio_test.go
Normal file
264
server/server_twilio_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
||||
var called, verified atomic.Bool
|
||||
var code atomic.Pointer[string]
|
||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
||||
if code.Load() != nil {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
||||
code.Store(util.String("123456"))
|
||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
||||
if verified.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
||||
verified.Store(true)
|
||||
} else {
|
||||
t.Fatal("Unexpected path:", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer twilioVerifyServer.Close()
|
||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioCallsServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
c.TwilioVerifyService = "VA1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
// Send verification code for phone number
|
||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return *code.Load() == "123456"
|
||||
})
|
||||
|
||||
// Add phone number with code
|
||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return verified.Load()
|
||||
})
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(phoneNumbers))
|
||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
||||
|
||||
// Do the thing
|
||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
|
||||
// Remove the phone number
|
||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Verify the phone number is gone from the DB
|
||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(phoneNumbers))
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes", // <<<------
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+invalid",
|
||||
})
|
||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioFromNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+123123",
|
||||
})
|
||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+1234",
|
||||
})
|
||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
@ -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"`
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
142
user/manager.go
142
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{}
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
)
|
||||
|
210
web/package-lock.json
generated
210
web/package-lock.json
generated
@ -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": {
|
||||
|
@ -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"]
|
||||
};
|
||||
|
@ -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}}، انقر للبحث",
|
||||
|
@ -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": "Абониране",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "アクセストークンを生成",
|
||||
|
@ -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": "알림 효과음",
|
||||
|
@ -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",
|
||||
|
@ -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.",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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}} дней",
|
||||
|
@ -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 <docsLink>dokumentationen</docsLink> .",
|
||||
"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 <Link>dokumentationen</Link>.",
|
||||
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
||||
|
@ -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",
|
||||
|
@ -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}}",
|
||||
|
@ -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": "创建访问令牌",
|
||||
|
@ -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": "新增使用者",
|
||||
|
@ -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()) {
|
||||
|
@ -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) => {
|
||||
|
@ -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 = () => {
|
||||
<PrefGroup>
|
||||
<Username/>
|
||||
<ChangePassword/>
|
||||
<PhoneNumbers/>
|
||||
<AccountType/>
|
||||
</PrefGroup>
|
||||
</Card>
|
||||
@ -319,6 +326,206 @@ const AccountType = () => {
|
||||
)
|
||||
};
|
||||
|
||||
const PhoneNumbers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { account } = useContext(AccountContext);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const labelId = "prefPhoneNumbers";
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
setDialogKey(prev => prev+1);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = (phoneNumber) => {
|
||||
navigator.clipboard.writeText(phoneNumber);
|
||||
setSnackOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (phoneNumber) => {
|
||||
try {
|
||||
await accountApi.deletePhoneNumber(phoneNumber);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error deleting phone number`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!config.enable_calls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (account?.limits.calls === 0) {
|
||||
return (
|
||||
<Pref title={<>{t("account_basics_phone_numbers_title")}{config.enable_payments && <ProChip/>}</>} description={t("account_basics_phone_numbers_description")}>
|
||||
<em>{t("account_usage_calls_none")}</em>
|
||||
</Pref>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{account?.phone_numbers?.map(phoneNumber =>
|
||||
<Chip
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{phoneNumber}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(phoneNumber)}
|
||||
onDelete={() => handleDelete(phoneNumber)}
|
||||
/>
|
||||
)}
|
||||
{!account?.phone_numbers &&
|
||||
<em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
|
||||
}
|
||||
<IconButton onClick={handleDialogOpen}><AddIcon/></IconButton>
|
||||
</div>
|
||||
<AddPhoneNumberDialog
|
||||
key={`addPhoneNumberDialog${dialogKey}`}
|
||||
open={dialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("account_basics_phone_numbers_copied_to_clipboard")}
|
||||
/>
|
||||
</Portal>
|
||||
</Pref>
|
||||
)
|
||||
};
|
||||
|
||||
const AddPhoneNumberDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [channel, setChannel] = useState("sms");
|
||||
const [code, setCode] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleDialogSubmit = async () => {
|
||||
if (!verificationCodeSent) {
|
||||
await verifyPhone();
|
||||
} else {
|
||||
await checkVerifyPhone();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (verificationCodeSent) {
|
||||
setVerificationCodeSent(false);
|
||||
setCode("");
|
||||
} else {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const verifyPhone = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.verifyPhoneNumber(phoneNumber, channel);
|
||||
setVerificationCodeSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error sending verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkVerifyPhone = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.addPhoneNumber(phoneNumber, code);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error confirming verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t("account_basics_phone_numbers_dialog_description")}
|
||||
</DialogContentText>
|
||||
{!verificationCodeSent &&
|
||||
<div style={{display: "flex"}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_phone_numbers_dialog_number_label")}
|
||||
aria-label={t("account_basics_phone_numbers_dialog_number_label")}
|
||||
placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={ev => setPhoneNumber(ev.target.value)}
|
||||
inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }}
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<FormControl sx={{ flexWrap: "nowrap" }}>
|
||||
<RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
|
||||
<FormControlLabel value="sms" control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} />
|
||||
<FormControlLabel value="call" control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</div>
|
||||
}
|
||||
{verificationCodeSent &&
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_phone_numbers_dialog_code_label")}
|
||||
aria-label={t("account_basics_phone_numbers_dialog_code_label")}
|
||||
placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={ev => setCode(ev.target.value)}
|
||||
fullWidth
|
||||
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
|
||||
variant="standard"
|
||||
/>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
||||
<Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
|
||||
{!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
|
||||
{!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
|
||||
{verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const Stats = () => {
|
||||
const { t } = useTranslation();
|
||||
const { account } = useContext(AccountContext);
|
||||
@ -337,23 +544,18 @@ const Stats = () => {
|
||||
{t("account_usage_title")}
|
||||
</Typography>
|
||||
<PrefGroup>
|
||||
<Pref title={t("account_usage_reservations_title")}>
|
||||
{(account.role === Role.ADMIN || account.limits.reservations > 0) &&
|
||||
<>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{account.role === Role.USER && account.limits.reservations === 0 &&
|
||||
<em>{t("account_usage_reservations_none")}</em>
|
||||
}
|
||||
</Pref>
|
||||
{(account.role === Role.ADMIN || account.limits.reservations > 0) &&
|
||||
<Pref title={t("account_usage_reservations_title")}>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations.toLocaleString()}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
}
|
||||
<Pref title={
|
||||
<>
|
||||
{t("account_usage_messages_title")}
|
||||
@ -361,29 +563,48 @@ const Stats = () => {
|
||||
</>
|
||||
}>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages.toLocaleString()}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
<Pref title={
|
||||
<>
|
||||
{t("account_usage_emails_title")}
|
||||
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||
</>
|
||||
}>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
{config.enable_emails &&
|
||||
<Pref title={
|
||||
<>
|
||||
{t("account_usage_emails_title")}
|
||||
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||
</>
|
||||
}>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails.toLocaleString()}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
}
|
||||
{config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) &&
|
||||
<Pref title={
|
||||
<>
|
||||
{t("account_usage_calls_title")}
|
||||
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||
</>
|
||||
}>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.calls.toLocaleString()}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
}
|
||||
<Pref
|
||||
alignTop
|
||||
title={t("account_usage_attachment_storage_title")}
|
||||
@ -404,6 +625,16 @@ const Stats = () => {
|
||||
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
|
||||
/>
|
||||
</Pref>
|
||||
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 &&
|
||||
<Pref title={<>{t("account_usage_reservations_title")}{config.enable_payments && <ProChip/>}</>}>
|
||||
<em>{t("account_usage_reservations_none")}</em>
|
||||
</Pref>
|
||||
}
|
||||
{config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
|
||||
<Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
|
||||
<em>{t("account_usage_calls_none")}</em>
|
||||
</Pref>
|
||||
}
|
||||
</PrefGroup>
|
||||
{account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
|
||||
<Typography variant="body1">
|
||||
@ -535,7 +766,7 @@ const TokensTable = (props) => {
|
||||
<span>
|
||||
<span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span>
|
||||
...
|
||||
<Tooltip title={t("account_tokens_table_copy_to_clipboard")} placement="right">
|
||||
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||
<IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
@ -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) => {
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showCall &&
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
|
||||
setCall("");
|
||||
setShowCall(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_call_label")}
|
||||
placeholder={t("publish_dialog_call_placeholder")}
|
||||
value={call}
|
||||
onChange={ev => setCall(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="tel"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_call_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showAttachUrl &&
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
||||
setAttachUrl("");
|
||||
@ -510,6 +536,7 @@ const PublishDialog = (props) => {
|
||||
<div>
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
|
@ -288,7 +288,7 @@ const LoginPage = (props) => {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
|
||||
<Button onClick={props.onBack}>{t("common_back")}</Button>
|
||||
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
@ -277,14 +277,14 @@ const LimitReachedChip = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ProChip = () => {
|
||||
export const ProChip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Chip
|
||||
label={"ntfy Pro"}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
|
||||
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -298,11 +298,12 @@ const TierCard = (props) => {
|
||||
</div>
|
||||
<List dense>
|
||||
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
|
||||
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
|
||||
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
|
||||
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
|
||||
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
|
||||
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
|
||||
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature>
|
||||
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
|
||||
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
|
||||
</List>
|
||||
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
|
||||
<Typography variant="body2" color="gray">
|
||||
|
Loading…
Reference in New Issue
Block a user