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-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||||
|
@ -151,6 +155,10 @@ func execServe(c *cli.Context) error {
|
||||||
smtpServerListen := c.String("smtp-server-listen")
|
smtpServerListen := c.String("smtp-server-listen")
|
||||||
smtpServerDomain := c.String("smtp-server-domain")
|
smtpServerDomain := c.String("smtp-server-domain")
|
||||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
|
twilioAccount := c.String("twilio-account")
|
||||||
|
twilioAuthToken := c.String("twilio-auth-token")
|
||||||
|
twilioFromNumber := c.String("twilio-from-number")
|
||||||
|
twilioVerifyService := c.String("twilio-verify-service")
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||||
|
@ -209,6 +217,8 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
|
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||||
|
return errors.New("if twilio-account is set, twilio-auth-token, twilio-from-number, twilio-verify-service, base-url, and auth-file must also be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
|
@ -308,6 +318,10 @@ func execServe(c *cli.Context) error {
|
||||||
conf.SMTPServerListen = smtpServerListen
|
conf.SMTPServerListen = smtpServerListen
|
||||||
conf.SMTPServerDomain = smtpServerDomain
|
conf.SMTPServerDomain = smtpServerDomain
|
||||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||||
|
conf.TwilioAccount = twilioAccount
|
||||||
|
conf.TwilioAuthToken = twilioAuthToken
|
||||||
|
conf.TwilioFromNumber = twilioFromNumber
|
||||||
|
conf.TwilioVerifyService = twilioVerifyService
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
|
|
|
@ -18,6 +18,7 @@ const (
|
||||||
defaultMessageLimit = 5000
|
defaultMessageLimit = 5000
|
||||||
defaultMessageExpiryDuration = "12h"
|
defaultMessageExpiryDuration = "12h"
|
||||||
defaultEmailLimit = 20
|
defaultEmailLimit = 20
|
||||||
|
defaultCallLimit = 0
|
||||||
defaultReservationLimit = 3
|
defaultReservationLimit = 3
|
||||||
defaultAttachmentFileSizeLimit = "15M"
|
defaultAttachmentFileSizeLimit = "15M"
|
||||||
defaultAttachmentTotalSizeLimit = "100M"
|
defaultAttachmentTotalSizeLimit = "100M"
|
||||||
|
@ -48,6 +49,7 @@ var cmdTier = &cli.Command{
|
||||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||||
|
&cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||||
|
@ -91,6 +93,7 @@ Examples:
|
||||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||||
|
&cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"},
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||||
|
@ -215,6 +218,7 @@ func execTierAdd(c *cli.Context) error {
|
||||||
MessageLimit: c.Int64("message-limit"),
|
MessageLimit: c.Int64("message-limit"),
|
||||||
MessageExpiryDuration: messageExpiryDuration,
|
MessageExpiryDuration: messageExpiryDuration,
|
||||||
EmailLimit: c.Int64("email-limit"),
|
EmailLimit: c.Int64("email-limit"),
|
||||||
|
CallLimit: c.Int64("call-limit"),
|
||||||
ReservationLimit: c.Int64("reservation-limit"),
|
ReservationLimit: c.Int64("reservation-limit"),
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||||
|
@ -267,6 +271,9 @@ func execTierChange(c *cli.Context) error {
|
||||||
if c.IsSet("email-limit") {
|
if c.IsSet("email-limit") {
|
||||||
tier.EmailLimit = c.Int64("email-limit")
|
tier.EmailLimit = c.Int64("email-limit")
|
||||||
}
|
}
|
||||||
|
if c.IsSet("call-limit") {
|
||||||
|
tier.CallLimit = c.Int64("call-limit")
|
||||||
|
}
|
||||||
if c.IsSet("reservation-limit") {
|
if c.IsSet("reservation-limit") {
|
||||||
tier.ReservationLimit = c.Int64("reservation-limit")
|
tier.ReservationLimit = c.Int64("reservation-limit")
|
||||||
}
|
}
|
||||||
|
@ -357,6 +364,7 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
||||||
|
|
|
@ -814,6 +814,7 @@ ntfy tier add \
|
||||||
--message-limit=10000 \
|
--message-limit=10000 \
|
||||||
--message-expiry-duration=24h \
|
--message-expiry-duration=24h \
|
||||||
--email-limit=50 \
|
--email-limit=50 \
|
||||||
|
--call-limit=10 \
|
||||||
--reservation-limit=10 \
|
--reservation-limit=10 \
|
||||||
--attachment-file-size-limit=100M \
|
--attachment-file-size-limit=100M \
|
||||||
--attachment-total-size-limit=1G \
|
--attachment-total-size-limit=1G \
|
||||||
|
@ -854,6 +855,22 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||||
billing-contact: "phil@example.com"
|
billing-contact: "phil@example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Phone calls
|
||||||
|
ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled,
|
||||||
|
users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header.
|
||||||
|
See [publishing page](publish.md#phone-calls) for more details.
|
||||||
|
|
||||||
|
To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers
|
||||||
|
are the easiest), and then configure the following options:
|
||||||
|
|
||||||
|
* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
||||||
|
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||||
|
* `twilio-from-number` is the outgoing phone number you purchased, e.g. +18775132586
|
||||||
|
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||||
|
|
||||||
|
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||||
|
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
|
|
131
docs/publish.md
131
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>
|
<figcaption>Publishing a message via e-mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
## Phone calls
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
|
You can use ntfy to call a phone and **read the message out loud using text-to-speech**.
|
||||||
|
Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have
|
||||||
|
the ntfy app installed on their phone.
|
||||||
|
|
||||||
|
**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is
|
||||||
|
**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone
|
||||||
|
number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`.
|
||||||
|
You may also simply pass `yes` as a value to pick the first of your verified phone numbers.
|
||||||
|
On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
![phone number verification](static/img/web-phone-verify.png)
|
||||||
|
<figcaption>Phone number verification in the <a href="https://ntfy.sh/account">web app</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll
|
||||||
|
be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues).
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**.
|
||||||
|
This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or
|
||||||
|
violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated.
|
||||||
|
|
||||||
|
Here's how you use it:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||||
|
-H "Call: +12223334444" \
|
||||||
|
-d "Your garage seems to be on fire. You should probably check that out." \
|
||||||
|
ntfy.sh/alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||||
|
--call=+12223334444 \
|
||||||
|
alerts "Your garage seems to be on fire. You should probably check that out."
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /alerts HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||||
|
Call: +12223334444
|
||||||
|
|
||||||
|
Your garage seems to be on fire. You should probably check that out.
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: "Your garage seems to be on fire. You should probably check that out.",
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
|
||||||
|
'Call': '+12223334444'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
|
||||||
|
strings.NewReader("Your garage seems to be on fire. You should probably check that out."))
|
||||||
|
req.Header.Set("Call", "+12223334444")
|
||||||
|
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$Request = @{
|
||||||
|
Method = "POST"
|
||||||
|
URI = "https://ntfy.sh/alerts"
|
||||||
|
Headers = @{
|
||||||
|
Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
|
||||||
|
Call = "+12223334444"
|
||||||
|
}
|
||||||
|
Body = "Your garage seems to be on fire. You should probably check that out."
|
||||||
|
}
|
||||||
|
Invoke-RestMethod @Request
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/alerts",
|
||||||
|
data="Your garage seems to be on fire. You should probably check that out.",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2",
|
||||||
|
"Call": "+12223334444"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" .
|
||||||
|
"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" .
|
||||||
|
"Call: +12223334444",
|
||||||
|
'content' => 'Your garage seems to be on fire. You should probably check that out.'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's what a phone call from ntfy sounds like:
|
||||||
|
|
||||||
|
<audio controls>
|
||||||
|
<source src="../static/audio/ntfy-phone-call.mp3" type="audio/mpeg">
|
||||||
|
<source src="../static/audio/ntfy-phone-call.ogg" type="audio/ogg">
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
Audio transcript:
|
||||||
|
|
||||||
|
> You have a notification from ntfy on topic alerts.
|
||||||
|
> Message: Your garage seems to be on fire. You should probably check that out. End message.
|
||||||
|
> This message was sent by user phil. It will be repeated up to three times.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
||||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
|
@ -3315,11 +3442,12 @@ are configurable via the server side [rate limiting settings](config.md#rate-lim
|
||||||
but just in case, let's list them all:
|
but just in case, let's list them all:
|
||||||
|
|
||||||
| Limit | Description |
|
| Limit | Description |
|
||||||
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||||
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. |
|
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. |
|
||||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. |
|
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. |
|
||||||
|
| **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. |
|
| **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 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 expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||||
|
@ -3353,6 +3481,7 @@ table in their canonical form.
|
||||||
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
||||||
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
||||||
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||||
|
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
|
||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
||||||
|
|
|
@ -1182,6 +1182,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
|
* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)
|
||||||
* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
|
* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
2
go.mod
2
go.mod
|
@ -33,7 +33,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.110.2 // indirect
|
cloud.google.com/go v0.110.2 // indirect
|
||||||
cloud.google.com/go/compute v1.19.2 // indirect
|
cloud.google.com/go/compute v1.19.3 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||||
cloud.google.com/go/iam v1.0.1 // indirect
|
cloud.google.com/go/iam v1.0.1 // indirect
|
||||||
cloud.google.com/go/longrunning v0.4.2 // indirect
|
cloud.google.com/go/longrunning v0.4.2 // indirect
|
||||||
|
|
22
go.sum
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.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA=
|
|
||||||
cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw=
|
|
||||||
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
|
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
|
||||||
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
|
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
|
||||||
cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
|
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
|
||||||
cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
|
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
|
||||||
cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY=
|
|
||||||
cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08=
|
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc=
|
|
||||||
cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew=
|
|
||||||
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
|
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
|
||||||
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
|
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
|
||||||
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
|
||||||
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
|
||||||
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
|
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
|
||||||
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
|
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
|
||||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||||
|
@ -147,8 +139,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v74 v74.17.0 h1:qVWSzmADr6gudznuAcPjB9ewzgxfyIhBCkyTbkxJcCw=
|
|
||||||
github.com/stripe/stripe-go/v74 v74.17.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
|
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
||||||
|
@ -163,8 +153,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
@ -186,14 +174,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
|
|
||||||
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
|
||||||
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
|
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
|
||||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
@ -241,8 +225,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow=
|
|
||||||
google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
|
||||||
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
|
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
|
||||||
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
|
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
|
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
||||||
func (e *Event) Fatal(message string, v ...any) {
|
func (e *Event) Fatal(message string, v ...any) {
|
||||||
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
|
e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
|
||||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs the event with log level error
|
// Error logs the event with log level error
|
||||||
func (e *Event) Error(message string, v ...any) {
|
func (e *Event) Error(message string, v ...any) *Event {
|
||||||
e.maybeLog(ErrorLevel, message, v...)
|
return e.Log(ErrorLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs the event with log level warn
|
// Warn logs the event with log level warn
|
||||||
func (e *Event) Warn(message string, v ...any) {
|
func (e *Event) Warn(message string, v ...any) *Event {
|
||||||
e.maybeLog(WarnLevel, message, v...)
|
return e.Log(WarnLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs the event with log level info
|
// Info logs the event with log level info
|
||||||
func (e *Event) Info(message string, v ...any) {
|
func (e *Event) Info(message string, v ...any) *Event {
|
||||||
e.maybeLog(InfoLevel, message, v...)
|
return e.Log(InfoLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs the event with log level debug
|
// Debug logs the event with log level debug
|
||||||
func (e *Event) Debug(message string, v ...any) {
|
func (e *Event) Debug(message string, v ...any) *Event {
|
||||||
e.maybeLog(DebugLevel, message, v...)
|
return e.Log(DebugLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace logs the event with log level trace
|
// Trace logs the event with log level trace
|
||||||
func (e *Event) Trace(message string, v ...any) {
|
func (e *Event) Trace(message string, v ...any) *Event {
|
||||||
e.maybeLog(TraceLevel, message, v...)
|
return e.Log(TraceLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag adds a "tag" field to the log event
|
// Tag adds a "tag" field to the log event
|
||||||
|
@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FieldIf adds a custom field and value to the log event if the given level is loggable
|
||||||
|
func (e *Event) FieldIf(key string, value any, level Level) *Event {
|
||||||
|
if e.Loggable(level) {
|
||||||
|
return e.Field(key, value)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
// Fields adds a map of fields to the log event
|
// Fields adds a map of fields to the log event
|
||||||
func (e *Event) Fields(fields Context) *Event {
|
func (e *Event) Fields(fields Context) *Event {
|
||||||
if e.fields == nil {
|
if e.fields == nil {
|
||||||
|
@ -138,7 +146,7 @@ func (e *Event) With(contexters ...Contexter) *Event {
|
||||||
// to determine if they match. This is super complicated, but required for efficiency.
|
// to determine if they match. This is super complicated, but required for efficiency.
|
||||||
func (e *Event) Render(l Level, message string, v ...any) string {
|
func (e *Event) Render(l Level, message string, v ...any) string {
|
||||||
appliedContexters := e.maybeApplyContexters()
|
appliedContexters := e.maybeApplyContexters()
|
||||||
if !e.shouldLog(l) {
|
if !e.Loggable(l) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
e.Message = fmt.Sprintf(message, v...)
|
e.Message = fmt.Sprintf(message, v...)
|
||||||
|
@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string {
|
||||||
return e.String()
|
return e.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string
|
// Log logs the event to the defined output, or does nothing if Render returns an empty string
|
||||||
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
func (e *Event) Log(l Level, message string, v ...any) *Event {
|
||||||
if m := e.Render(l, message, v...); m != "" {
|
if m := e.Render(l, message, v...); m != "" {
|
||||||
log.Println(m)
|
log.Println(m)
|
||||||
}
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
@ -199,10 +208,6 @@ func (e *Event) String() string {
|
||||||
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Event) shouldLog(l Level) bool {
|
|
||||||
return e.globalLevelWithOverride() <= l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) globalLevelWithOverride() Level {
|
func (e *Event) globalLevelWithOverride() Level {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
l, ov := level, overrides
|
l, ov := level, overrides
|
||||||
|
|
|
@ -198,6 +198,30 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
||||||
require.Equal(t, "", File())
|
require.Equal(t, "", File())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLog_FieldIf(t *testing.T) {
|
||||||
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
SetOutput(&out)
|
||||||
|
SetLevel(DebugLevel)
|
||||||
|
SetFormat(JSONFormat)
|
||||||
|
|
||||||
|
Time(time.Unix(11, 0).UTC()).
|
||||||
|
FieldIf("trace_field", "manager", TraceLevel). // This is not logged
|
||||||
|
Field("tag", "manager").
|
||||||
|
Debug("trace_field is not logged")
|
||||||
|
SetLevel(TraceLevel)
|
||||||
|
Time(time.Unix(12, 0).UTC()).
|
||||||
|
FieldIf("trace_field", "manager", TraceLevel). // Now it is logged
|
||||||
|
Field("tag", "manager").
|
||||||
|
Debug("trace_field is logged")
|
||||||
|
|
||||||
|
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"}
|
||||||
|
{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"}
|
||||||
|
`
|
||||||
|
require.Equal(t, expected, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||||
t.Cleanup(resetState)
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,12 @@ type Config struct {
|
||||||
SMTPServerListen string
|
SMTPServerListen string
|
||||||
SMTPServerDomain string
|
SMTPServerDomain string
|
||||||
SMTPServerAddrPrefix string
|
SMTPServerAddrPrefix string
|
||||||
|
TwilioAccount string
|
||||||
|
TwilioAuthToken string
|
||||||
|
TwilioFromNumber string
|
||||||
|
TwilioCallsBaseURL string
|
||||||
|
TwilioVerifyBaseURL string
|
||||||
|
TwilioVerifyService string
|
||||||
MetricsEnable bool
|
MetricsEnable bool
|
||||||
MetricsListenHTTP string
|
MetricsListenHTTP string
|
||||||
ProfileListenHTTP string
|
ProfileListenHTTP string
|
||||||
|
@ -183,6 +189,12 @@ func NewConfig() *Config {
|
||||||
SMTPServerListen: "",
|
SMTPServerListen: "",
|
||||||
SMTPServerDomain: "",
|
SMTPServerDomain: "",
|
||||||
SMTPServerAddrPrefix: "",
|
SMTPServerAddrPrefix: "",
|
||||||
|
TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
|
||||||
|
TwilioAccount: "",
|
||||||
|
TwilioAuthToken: "",
|
||||||
|
TwilioFromNumber: "",
|
||||||
|
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||||
|
TwilioVerifyService: "",
|
||||||
MessageLimit: DefaultMessageLengthLimit,
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
MinDelay: DefaultMinDelay,
|
MinDelay: DefaultMinDelay,
|
||||||
MaxDelay: DefaultMaxDelay,
|
MaxDelay: DefaultMaxDelay,
|
||||||
|
|
|
@ -108,12 +108,20 @@ var (
|
||||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
||||||
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
|
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
|
||||||
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
|
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
|
||||||
|
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil}
|
||||||
|
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
|
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
|
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
|
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
|
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
||||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||||
|
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
||||||
|
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||||
|
@ -126,6 +134,7 @@ var (
|
||||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||||
|
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||||
|
|
|
@ -20,6 +20,7 @@ const (
|
||||||
tagFirebase = "firebase"
|
tagFirebase = "firebase"
|
||||||
tagSMTP = "smtp" // Receive email
|
tagSMTP = "smtp" // Receive email
|
||||||
tagEmail = "email" // Send email
|
tagEmail = "email" // Send email
|
||||||
|
tagTwilio = "twilio"
|
||||||
tagFileCache = "file_cache"
|
tagFileCache = "file_cache"
|
||||||
tagMessageCache = "message_cache"
|
tagMessageCache = "message_cache"
|
||||||
tagStripe = "stripe"
|
tagStripe = "stripe"
|
||||||
|
|
|
@ -90,6 +90,8 @@ var (
|
||||||
apiAccountSettingsPath = "/v1/account/settings"
|
apiAccountSettingsPath = "/v1/account/settings"
|
||||||
apiAccountSubscriptionPath = "/v1/account/subscription"
|
apiAccountSubscriptionPath = "/v1/account/subscription"
|
||||||
apiAccountReservationPath = "/v1/account/reservation"
|
apiAccountReservationPath = "/v1/account/reservation"
|
||||||
|
apiAccountPhonePath = "/v1/account/phone"
|
||||||
|
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
||||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||||
|
@ -100,6 +102,7 @@ var (
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||||
urlRegex = regexp.MustCompile(`^https?://`)
|
urlRegex = regexp.MustCompile(`^https?://`)
|
||||||
|
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
|
||||||
|
|
||||||
//go:embed site
|
//go:embed site
|
||||||
webFs embed.FS
|
webFs embed.FS
|
||||||
|
@ -461,6 +464,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||||
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
||||||
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
||||||
|
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath {
|
||||||
|
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {
|
||||||
|
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
|
||||||
|
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
|
||||||
return s.handleStats(w, r, v)
|
return s.handleStats(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
||||||
|
@ -540,6 +549,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
EnableSignup: s.config.EnableSignup,
|
EnableSignup: s.config.EnableSignup,
|
||||||
EnablePayments: s.config.StripeSecretKey != "",
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
|
EnableCalls: s.config.TwilioAccount != "",
|
||||||
|
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||||
EnableReservations: s.config.EnableReservations,
|
EnableReservations: s.config.EnableReservations,
|
||||||
BillingContact: s.config.BillingContact,
|
BillingContact: s.config.BillingContact,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
|
@ -683,7 +694,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
m := newDefaultMessage(t.ID, "")
|
||||||
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m)
|
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e.With(t)
|
return nil, e.With(t)
|
||||||
}
|
}
|
||||||
|
@ -697,6 +708,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||||
} else if email != "" && !vrate.EmailAllowed() {
|
} else if email != "" && !vrate.EmailAllowed() {
|
||||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||||
|
} else if call != "" {
|
||||||
|
var httpErr *errHTTP
|
||||||
|
call, httpErr = s.convertPhoneNumber(v.User(), call)
|
||||||
|
if httpErr != nil {
|
||||||
|
return nil, httpErr.With(t)
|
||||||
|
} else if !vrate.CallAllowed() {
|
||||||
|
return nil, errHTTPTooManyRequestsLimitCalls.With(t)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if m.PollID != "" {
|
if m.PollID != "" {
|
||||||
m = newPollRequestMessage(t.ID, m.PollID)
|
m = newPollRequestMessage(t.ID, m.PollID)
|
||||||
|
@ -721,6 +740,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
"message_firebase": firebase,
|
"message_firebase": firebase,
|
||||||
"message_unifiedpush": unifiedpush,
|
"message_unifiedpush": unifiedpush,
|
||||||
"message_email": email,
|
"message_email": email,
|
||||||
|
"message_call": call,
|
||||||
})
|
})
|
||||||
if ev.IsTrace() {
|
if ev.IsTrace() {
|
||||||
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
|
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
|
||||||
|
@ -737,6 +757,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
if s.smtpSender != nil && email != "" {
|
if s.smtpSender != nil && email != "" {
|
||||||
go s.sendEmail(v, m, email)
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
|
if s.config.TwilioAccount != "" && call != "" {
|
||||||
|
go s.callPhone(v, r, m, call)
|
||||||
|
}
|
||||||
if s.config.UpstreamBaseURL != "" {
|
if s.config.UpstreamBaseURL != "" {
|
||||||
go s.forwardPollRequest(v, m)
|
go s.forwardPollRequest(v, m)
|
||||||
}
|
}
|
||||||
|
@ -846,7 +869,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
|
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
|
||||||
|
@ -862,7 +885,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
}
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
|
@ -880,13 +903,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
|
||||||
|
}
|
||||||
|
call = readParam(r, "x-call", "call")
|
||||||
|
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||||
|
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||||
|
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||||
|
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
|
@ -895,7 +924,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
for i, t := range m.Tags {
|
for i, t := range m.Tags {
|
||||||
|
@ -904,18 +933,21 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
|
}
|
||||||
|
if call != "" {
|
||||||
|
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayCannotParse
|
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayTooSmall
|
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayTooLarge
|
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
|
@ -923,7 +955,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
|
@ -937,7 +969,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
cache = false
|
cache = false
|
||||||
email = ""
|
email = ""
|
||||||
}
|
}
|
||||||
return cache, firebase, email, unifiedpush, nil
|
return cache, firebase, email, call, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
|
|
|
@ -144,6 +144,18 @@
|
||||||
# smtp-server-domain:
|
# smtp-server-domain:
|
||||||
# smtp-server-addr-prefix:
|
# smtp-server-addr-prefix:
|
||||||
|
|
||||||
|
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
|
||||||
|
#
|
||||||
|
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
||||||
|
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||||
|
# - twilio-from-number is the outgoing phone number you purchased, e.g. +18775132586
|
||||||
|
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||||
|
#
|
||||||
|
# twilio-account:
|
||||||
|
# twilio-auth-token:
|
||||||
|
# twilio-from-number:
|
||||||
|
# twilio-verify-service:
|
||||||
|
|
||||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||||
# intermediaries closing the connection for inactivity.
|
# intermediaries closing the connection for inactivity.
|
||||||
#
|
#
|
||||||
|
|
|
@ -56,6 +56,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
Messages: limits.MessageLimit,
|
Messages: limits.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
||||||
Emails: limits.EmailLimit,
|
Emails: limits.EmailLimit,
|
||||||
|
Calls: limits.CallLimit,
|
||||||
Reservations: limits.ReservationsLimit,
|
Reservations: limits.ReservationsLimit,
|
||||||
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
|
@ -67,6 +68,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
MessagesRemaining: stats.MessagesRemaining,
|
MessagesRemaining: stats.MessagesRemaining,
|
||||||
Emails: stats.Emails,
|
Emails: stats.Emails,
|
||||||
EmailsRemaining: stats.EmailsRemaining,
|
EmailsRemaining: stats.EmailsRemaining,
|
||||||
|
Calls: stats.Calls,
|
||||||
|
CallsRemaining: stats.CallsRemaining,
|
||||||
Reservations: stats.Reservations,
|
Reservations: stats.Reservations,
|
||||||
ReservationsRemaining: stats.ReservationsRemaining,
|
ReservationsRemaining: stats.ReservationsRemaining,
|
||||||
AttachmentTotalSize: stats.AttachmentTotalSize,
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||||
|
@ -105,6 +108,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.config.EnableReservations {
|
||||||
reservations, err := s.userManager.Reservations(u.Name)
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -118,6 +122,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
tokens, err := s.userManager.Tokens(u.ID)
|
tokens, err := s.userManager.Tokens(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -138,6 +143,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.config.TwilioAccount != "" {
|
||||||
|
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(phoneNumbers) > 0 {
|
||||||
|
response.PhoneNumbers = phoneNumbers
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
|
@ -511,6 +525,72 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
u := v.User()
|
||||||
|
req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !phoneNumberRegex.MatchString(req.Number) {
|
||||||
|
return errHTTPBadRequestPhoneNumberInvalid
|
||||||
|
} else if req.Channel != "sms" && req.Channel != "call" {
|
||||||
|
return errHTTPBadRequestPhoneNumberVerifyChannelInvalid
|
||||||
|
}
|
||||||
|
// Check user is allowed to add phone numbers
|
||||||
|
if u == nil || (u.IsUser() && u.Tier == nil) {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
} else if u.IsUser() && u.Tier.CallLimit == 0 {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
// Check if phone number exists
|
||||||
|
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if util.Contains(phoneNumbers, req.Number) {
|
||||||
|
return errHTTPConflictPhoneNumberExists
|
||||||
|
}
|
||||||
|
// Actually add the unverified number, and send verification
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
|
||||||
|
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
u := v.User()
|
||||||
|
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !phoneNumberRegex.MatchString(req.Number) {
|
||||||
|
return errHTTPBadRequestPhoneNumberInvalid
|
||||||
|
}
|
||||||
|
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
|
||||||
|
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
u := v.User()
|
||||||
|
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !phoneNumberRegex.MatchString(req.Number) {
|
||||||
|
return errHTTPBadRequestPhoneNumberInvalid
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
|
||||||
|
if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
||||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
@ -151,6 +151,8 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
||||||
require.Equal(t, int64(0), account.Stats.Emails)
|
require.Equal(t, int64(0), account.Stats.Emails)
|
||||||
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Calls)
|
||||||
|
require.Equal(t, int64(0), account.Stats.CallsRemaining)
|
||||||
|
|
||||||
rr = request(t, s, "POST", "/mytopic", "", nil)
|
rr = request(t, s, "POST", "/mytopic", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -498,6 +500,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||||
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
|
conf.EnableReservations = true
|
||||||
|
conf.TwilioAccount = "dummy"
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
|
@ -510,6 +514,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
MessageLimit: 123,
|
MessageLimit: 123,
|
||||||
MessageExpiryDuration: 86400 * time.Second,
|
MessageExpiryDuration: 86400 * time.Second,
|
||||||
EmailLimit: 32,
|
EmailLimit: 32,
|
||||||
|
CallLimit: 10,
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
AttachmentFileSizeLimit: 1231231,
|
AttachmentFileSizeLimit: 1231231,
|
||||||
AttachmentTotalSizeLimit: 123123,
|
AttachmentTotalSizeLimit: 123123,
|
||||||
|
@ -551,6 +556,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
require.Equal(t, int64(123), account.Limits.Messages)
|
require.Equal(t, int64(123), account.Limits.Messages)
|
||||||
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
||||||
require.Equal(t, int64(32), account.Limits.Emails)
|
require.Equal(t, int64(32), account.Limits.Emails)
|
||||||
|
require.Equal(t, int64(10), account.Limits.Calls)
|
||||||
require.Equal(t, int64(2), account.Limits.Reservations)
|
require.Equal(t, int64(2), account.Limits.Reservations)
|
||||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||||
|
|
|
@ -15,6 +15,8 @@ var (
|
||||||
metricEmailsPublishedFailure prometheus.Counter
|
metricEmailsPublishedFailure prometheus.Counter
|
||||||
metricEmailsReceivedSuccess prometheus.Counter
|
metricEmailsReceivedSuccess prometheus.Counter
|
||||||
metricEmailsReceivedFailure prometheus.Counter
|
metricEmailsReceivedFailure prometheus.Counter
|
||||||
|
metricCallsMadeSuccess prometheus.Counter
|
||||||
|
metricCallsMadeFailure prometheus.Counter
|
||||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||||
metricMatrixPublishedSuccess prometheus.Counter
|
metricMatrixPublishedSuccess prometheus.Counter
|
||||||
metricMatrixPublishedFailure prometheus.Counter
|
metricMatrixPublishedFailure prometheus.Counter
|
||||||
|
@ -57,6 +59,12 @@ func initMetrics() {
|
||||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_emails_received_failure",
|
Name: "ntfy_emails_received_failure",
|
||||||
})
|
})
|
||||||
|
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "ntfy_calls_made_success",
|
||||||
|
})
|
||||||
|
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "ntfy_calls_made_failure",
|
||||||
|
})
|
||||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_unifiedpush_published_success",
|
Name: "ntfy_unifiedpush_published_success",
|
||||||
})
|
})
|
||||||
|
@ -95,6 +103,8 @@ func initMetrics() {
|
||||||
metricEmailsPublishedFailure,
|
metricEmailsPublishedFailure,
|
||||||
metricEmailsReceivedSuccess,
|
metricEmailsReceivedSuccess,
|
||||||
metricEmailsReceivedFailure,
|
metricEmailsReceivedFailure,
|
||||||
|
metricCallsMadeSuccess,
|
||||||
|
metricCallsMadeFailure,
|
||||||
metricUnifiedPushPublishedSuccess,
|
metricUnifiedPushPublishedSuccess,
|
||||||
metricMatrixPublishedSuccess,
|
metricMatrixPublishedSuccess,
|
||||||
metricMatrixPublishedFailure,
|
metricMatrixPublishedFailure,
|
||||||
|
|
|
@ -85,6 +85,15 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if s.config.TwilioAccount == "" || s.userManager == nil {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
||||||
|
|
|
@ -68,6 +68,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
Messages: freeTier.MessageLimit,
|
Messages: freeTier.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
||||||
Emails: freeTier.EmailLimit,
|
Emails: freeTier.EmailLimit,
|
||||||
|
Calls: freeTier.CallLimit,
|
||||||
Reservations: freeTier.ReservationsLimit,
|
Reservations: freeTier.ReservationsLimit,
|
||||||
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
||||||
|
@ -96,6 +97,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
Messages: tier.MessageLimit,
|
Messages: tier.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
||||||
Emails: tier.EmailLimit,
|
Emails: tier.EmailLimit,
|
||||||
|
Calls: tier.CallLimit,
|
||||||
Reservations: tier.ReservationLimit,
|
Reservations: tier.ReservationLimit,
|
||||||
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
||||||
|
|
|
@ -1190,7 +1190,20 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
"Delay": "20 min",
|
"Delay": "20 min",
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishDelayedCall_Fail(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.TwilioAccount = "AC1234567890"
|
||||||
|
c.TwilioAuthToken = "AAEAA1234567890"
|
||||||
|
c.TwilioFromNumber = "+1234567890"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
|
"Call": "yes",
|
||||||
|
"Delay": "20 min",
|
||||||
|
})
|
||||||
|
require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiAccountPhoneNumberVerifyRequest struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountPhoneNumberAddRequest struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
Code string `json:"code"` // Only set when adding a phone number
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountTier struct {
|
type apiAccountTier struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -321,6 +331,7 @@ type apiAccountLimits struct {
|
||||||
Messages int64 `json:"messages"`
|
Messages int64 `json:"messages"`
|
||||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||||
Emails int64 `json:"emails"`
|
Emails int64 `json:"emails"`
|
||||||
|
Calls int64 `json:"calls"`
|
||||||
Reservations int64 `json:"reservations"`
|
Reservations int64 `json:"reservations"`
|
||||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
AttachmentFileSize int64 `json:"attachment_file_size"`
|
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||||
|
@ -333,6 +344,8 @@ type apiAccountStats struct {
|
||||||
MessagesRemaining int64 `json:"messages_remaining"`
|
MessagesRemaining int64 `json:"messages_remaining"`
|
||||||
Emails int64 `json:"emails"`
|
Emails int64 `json:"emails"`
|
||||||
EmailsRemaining int64 `json:"emails_remaining"`
|
EmailsRemaining int64 `json:"emails_remaining"`
|
||||||
|
Calls int64 `json:"calls"`
|
||||||
|
CallsRemaining int64 `json:"calls_remaining"`
|
||||||
Reservations int64 `json:"reservations"`
|
Reservations int64 `json:"reservations"`
|
||||||
ReservationsRemaining int64 `json:"reservations_remaining"`
|
ReservationsRemaining int64 `json:"reservations_remaining"`
|
||||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
|
@ -362,6 +375,7 @@ type apiAccountResponse struct {
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||||
|
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
|
@ -379,6 +393,8 @@ type apiConfigResponse struct {
|
||||||
EnableLogin bool `json:"enable_login"`
|
EnableLogin bool `json:"enable_login"`
|
||||||
EnableSignup bool `json:"enable_signup"`
|
EnableSignup bool `json:"enable_signup"`
|
||||||
EnablePayments bool `json:"enable_payments"`
|
EnablePayments bool `json:"enable_payments"`
|
||||||
|
EnableCalls bool `json:"enable_calls"`
|
||||||
|
EnableEmails bool `json:"enable_emails"`
|
||||||
EnableReservations bool `json:"enable_reservations"`
|
EnableReservations bool `json:"enable_reservations"`
|
||||||
BillingContact string `json:"billing_contact"`
|
BillingContact string `json:"billing_contact"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
|
|
|
@ -18,6 +18,14 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
return toBool(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBoolValue(value string) bool {
|
||||||
|
return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBool(value string) bool {
|
||||||
return value == "1" || value == "yes" || value == "true"
|
return value == "1" || value == "yes" || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,10 @@ const (
|
||||||
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
||||||
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
||||||
visitorDefaultReservationsLimit = int64(0)
|
visitorDefaultReservationsLimit = int64(0)
|
||||||
|
|
||||||
|
// visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make.
|
||||||
|
// This number is zero, because phone numbers have to be verified first.
|
||||||
|
visitorDefaultCallsLimit = int64(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
||||||
|
@ -56,6 +60,7 @@ type visitor struct {
|
||||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||||
|
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||||
|
@ -79,6 +84,7 @@ type visitorLimits struct {
|
||||||
EmailLimit int64
|
EmailLimit int64
|
||||||
EmailLimitBurst int
|
EmailLimitBurst int
|
||||||
EmailLimitReplenish rate.Limit
|
EmailLimitReplenish rate.Limit
|
||||||
|
CallLimit int64
|
||||||
ReservationsLimit int64
|
ReservationsLimit int64
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
|
@ -91,6 +97,8 @@ type visitorStats struct {
|
||||||
MessagesRemaining int64
|
MessagesRemaining int64
|
||||||
Emails int64
|
Emails int64
|
||||||
EmailsRemaining int64
|
EmailsRemaining int64
|
||||||
|
Calls int64
|
||||||
|
CallsRemaining int64
|
||||||
Reservations int64
|
Reservations int64
|
||||||
ReservationsRemaining int64
|
ReservationsRemaining int64
|
||||||
AttachmentTotalSize int64
|
AttachmentTotalSize int64
|
||||||
|
@ -107,10 +115,11 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||||
var messages, emails int64
|
var messages, emails, calls int64
|
||||||
if user != nil {
|
if user != nil {
|
||||||
messages = user.Stats.Messages
|
messages = user.Stats.Messages
|
||||||
emails = user.Stats.Emails
|
emails = user.Stats.Emails
|
||||||
|
calls = user.Stats.Calls
|
||||||
}
|
}
|
||||||
v := &visitor{
|
v := &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
|
@ -124,11 +133,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
|
||||||
requestLimiter: nil, // Set in resetLimiters
|
requestLimiter: nil, // Set in resetLimiters
|
||||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
emailsLimiter: nil, // Set in resetLimiters
|
emailsLimiter: nil, // Set in resetLimiters
|
||||||
|
callsLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
bandwidthLimiter: nil, // Set in resetLimiters
|
bandwidthLimiter: nil, // Set in resetLimiters
|
||||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
}
|
}
|
||||||
v.resetLimitersNoLock(messages, emails, false)
|
v.resetLimitersNoLock(messages, emails, calls, false)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,12 +157,19 @@ func (v *visitor) contextNoLock() log.Context {
|
||||||
"visitor_messages": info.Stats.Messages,
|
"visitor_messages": info.Stats.Messages,
|
||||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||||
"visitor_emails": info.Stats.Emails,
|
|
||||||
"visitor_emails_limit": info.Limits.EmailLimit,
|
|
||||||
"visitor_emails_remaining": info.Stats.EmailsRemaining,
|
|
||||||
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
||||||
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
||||||
}
|
}
|
||||||
|
if v.config.SMTPSenderFrom != "" {
|
||||||
|
fields["visitor_emails"] = info.Stats.Emails
|
||||||
|
fields["visitor_emails_limit"] = info.Limits.EmailLimit
|
||||||
|
fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
|
||||||
|
}
|
||||||
|
if v.config.TwilioAccount != "" {
|
||||||
|
fields["visitor_calls"] = info.Stats.Calls
|
||||||
|
fields["visitor_calls_limit"] = info.Limits.CallLimit
|
||||||
|
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
|
||||||
|
}
|
||||||
if v.authLimiter != nil {
|
if v.authLimiter != nil {
|
||||||
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
||||||
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
||||||
|
@ -216,6 +233,12 @@ func (v *visitor) EmailAllowed() bool {
|
||||||
return v.emailsLimiter.Allow()
|
return v.emailsLimiter.Allow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) CallAllowed() bool {
|
||||||
|
v.mu.RLock() // limiters could be replaced!
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
return v.callsLimiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) SubscriptionAllowed() bool {
|
func (v *visitor) SubscriptionAllowed() bool {
|
||||||
v.mu.RLock() // limiters could be replaced!
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
|
@ -296,6 +319,7 @@ func (v *visitor) Stats() *user.Stats {
|
||||||
return &user.Stats{
|
return &user.Stats{
|
||||||
Messages: v.messagesLimiter.Value(),
|
Messages: v.messagesLimiter.Value(),
|
||||||
Emails: v.emailsLimiter.Value(),
|
Emails: v.emailsLimiter.Value(),
|
||||||
|
Calls: v.callsLimiter.Value(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,6 +328,7 @@ func (v *visitor) ResetStats() {
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
v.emailsLimiter.Reset()
|
v.emailsLimiter.Reset()
|
||||||
v.messagesLimiter.Reset()
|
v.messagesLimiter.Reset()
|
||||||
|
v.callsLimiter.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// User returns the visitor user, or nil if there is none
|
// User returns the visitor user, or nil if there is none
|
||||||
|
@ -334,11 +359,11 @@ func (v *visitor) SetUser(u *user.User) {
|
||||||
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
||||||
v.user = u // u may be nil!
|
v.user = u // u may be nil!
|
||||||
if shouldResetLimiters {
|
if shouldResetLimiters {
|
||||||
var messages, emails int64
|
var messages, emails, calls int64
|
||||||
if u != nil {
|
if u != nil {
|
||||||
messages, emails = u.Stats.Messages, u.Stats.Emails
|
messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls
|
||||||
}
|
}
|
||||||
v.resetLimitersNoLock(messages, emails, true)
|
v.resetLimitersNoLock(messages, emails, calls, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -353,11 +378,12 @@ func (v *visitor) MaybeUserID() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) {
|
func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {
|
||||||
limits := v.limitsNoLock()
|
limits := v.limitsNoLock()
|
||||||
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
|
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
|
||||||
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
||||||
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
||||||
|
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
|
||||||
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
||||||
if v.user == nil {
|
if v.user == nil {
|
||||||
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
||||||
|
@ -370,6 +396,7 @@ func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool
|
||||||
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
|
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
|
Calls: calls,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
|
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
|
||||||
|
@ -398,6 +425,7 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
|
||||||
EmailLimit: tier.EmailLimit,
|
EmailLimit: tier.EmailLimit,
|
||||||
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
|
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
|
||||||
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
|
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
|
||||||
|
CallLimit: tier.CallLimit,
|
||||||
ReservationsLimit: tier.ReservationLimit,
|
ReservationsLimit: tier.ReservationLimit,
|
||||||
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
|
||||||
|
@ -420,6 +448,7 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
|
||||||
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
||||||
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
||||||
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
||||||
|
CallLimit: visitorDefaultCallsLimit,
|
||||||
ReservationsLimit: visitorDefaultReservationsLimit,
|
ReservationsLimit: visitorDefaultReservationsLimit,
|
||||||
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
||||||
|
@ -465,12 +494,15 @@ func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
func (v *visitor) infoLightNoLock() *visitorInfo {
|
func (v *visitor) infoLightNoLock() *visitorInfo {
|
||||||
messages := v.messagesLimiter.Value()
|
messages := v.messagesLimiter.Value()
|
||||||
emails := v.emailsLimiter.Value()
|
emails := v.emailsLimiter.Value()
|
||||||
|
calls := v.callsLimiter.Value()
|
||||||
limits := v.limitsNoLock()
|
limits := v.limitsNoLock()
|
||||||
stats := &visitorStats{
|
stats := &visitorStats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
||||||
|
Calls: calls,
|
||||||
|
CallsRemaining: zeroIfNegative(limits.CallLimit - calls),
|
||||||
}
|
}
|
||||||
return &visitorInfo{
|
return &visitorInfo{
|
||||||
Limits: limits,
|
Limits: limits,
|
||||||
|
|
142
user/manager.go
142
user/manager.go
|
@ -6,7 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
"github.com/mattn/go-sqlite3"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
|
@ -55,6 +55,7 @@ const (
|
||||||
messages_limit INT NOT NULL,
|
messages_limit INT NOT NULL,
|
||||||
messages_expiry_duration INT NOT NULL,
|
messages_expiry_duration INT NOT NULL,
|
||||||
emails_limit INT NOT NULL,
|
emails_limit INT NOT NULL,
|
||||||
|
calls_limit INT NOT NULL,
|
||||||
reservations_limit INT NOT NULL,
|
reservations_limit INT NOT NULL,
|
||||||
attachment_file_size_limit INT NOT NULL,
|
attachment_file_size_limit INT NOT NULL,
|
||||||
attachment_total_size_limit INT NOT NULL,
|
attachment_total_size_limit INT NOT NULL,
|
||||||
|
@ -76,6 +77,7 @@ const (
|
||||||
sync_topic TEXT NOT NULL,
|
sync_topic TEXT NOT NULL,
|
||||||
stats_messages INT NOT NULL DEFAULT (0),
|
stats_messages INT NOT NULL DEFAULT (0),
|
||||||
stats_emails INT NOT NULL DEFAULT (0),
|
stats_emails INT NOT NULL DEFAULT (0),
|
||||||
|
stats_calls INT NOT NULL DEFAULT (0),
|
||||||
stripe_customer_id TEXT,
|
stripe_customer_id TEXT,
|
||||||
stripe_subscription_id TEXT,
|
stripe_subscription_id TEXT,
|
||||||
stripe_subscription_status TEXT,
|
stripe_subscription_status TEXT,
|
||||||
|
@ -109,6 +111,12 @@ const (
|
||||||
PRIMARY KEY (user_id, token),
|
PRIMARY KEY (user_id, token),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_phone (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
phone_number TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, phone_number),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
|
@ -123,26 +131,26 @@ const (
|
||||||
`
|
`
|
||||||
|
|
||||||
selectUserByIDQuery = `
|
selectUserByIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.id = ?
|
WHERE u.id = ?
|
||||||
`
|
`
|
||||||
selectUserByNameQuery = `
|
selectUserByNameQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE user = ?
|
WHERE user = ?
|
||||||
`
|
`
|
||||||
selectUserByTokenQuery = `
|
selectUserByTokenQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_token tk on u.id = tk.user_id
|
JOIN user_token tk on u.id = tk.user_id
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
||||||
`
|
`
|
||||||
selectUserByStripeCustomerIDQuery = `
|
selectUserByStripeCustomerIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.stripe_customer_id = ?
|
WHERE u.stripe_customer_id = ?
|
||||||
|
@ -173,8 +181,8 @@ const (
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
||||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
||||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
@ -262,26 +270,30 @@ const (
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?`
|
||||||
|
insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
|
||||||
|
deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||||
|
|
||||||
insertTierQuery = `
|
insertTierQuery = `
|
||||||
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
|
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
updateTierQuery = `
|
updateTierQuery = `
|
||||||
UPDATE tier
|
UPDATE tier
|
||||||
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
|
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
|
||||||
WHERE code = ?
|
WHERE code = ?
|
||||||
`
|
`
|
||||||
selectTiersQuery = `
|
selectTiersQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
`
|
`
|
||||||
selectTierByCodeQuery = `
|
selectTierByCodeQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
WHERE code = ?
|
WHERE code = ?
|
||||||
`
|
`
|
||||||
selectTierByPriceIDQuery = `
|
selectTierByPriceIDQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
|
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
|
||||||
`
|
`
|
||||||
|
@ -298,7 +310,7 @@ const (
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 3
|
currentSchemaVersion = 4
|
||||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
|
@ -396,12 +408,25 @@ const (
|
||||||
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
||||||
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 3 -> 4
|
||||||
|
migrate3To4UpdateQueries = `
|
||||||
|
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
|
||||||
|
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_phone (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
phone_number TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, phone_number),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
migrations = map[int]func(db *sql.DB) error{
|
migrations = map[int]func(db *sql.DB) error{
|
||||||
1: migrateFrom1,
|
1: migrateFrom1,
|
||||||
2: migrateFrom2,
|
2: migrateFrom2,
|
||||||
|
3: migrateFrom3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -623,6 +648,56 @@ func (a *Manager) RemoveExpiredTokens() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
||||||
|
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
||||||
|
rows, err := a.db.Query(selectPhoneNumbersQuery, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
phoneNumbers := make([]string, 0)
|
||||||
|
for {
|
||||||
|
phoneNumber, err := a.readPhoneNumber(rows)
|
||||||
|
if err == ErrPhoneNumberNotFound {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
phoneNumbers = append(phoneNumbers, phoneNumber)
|
||||||
|
}
|
||||||
|
return phoneNumbers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
||||||
|
var phoneNumber string
|
||||||
|
if !rows.Next() {
|
||||||
|
return "", ErrPhoneNumberNotFound
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&phoneNumber); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return phoneNumber, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPhoneNumber adds a phone number to the user with the given user ID
|
||||||
|
func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
|
||||||
|
if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
|
||||||
|
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
||||||
|
return ErrPhoneNumberExists
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePhoneNumber deletes a phone number from the user with the given user ID
|
||||||
|
func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error {
|
||||||
|
_, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveDeletedUsers deletes all users that have been marked deleted for
|
// RemoveDeletedUsers deletes all users that have been marked deleted for
|
||||||
func (a *Manager) RemoveDeletedUsers() error {
|
func (a *Manager) RemoveDeletedUsers() error {
|
||||||
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {
|
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {
|
||||||
|
@ -705,9 +780,10 @@ func (a *Manager) writeUserStatsQueue() error {
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"messages_count": update.Messages,
|
"messages_count": update.Messages,
|
||||||
"emails_count": update.Emails,
|
"emails_count": update.Emails,
|
||||||
|
"calls_count": update.Calls,
|
||||||
}).
|
}).
|
||||||
Trace("Updating stats for user %s", userID)
|
Trace("Updating stats for user %s", userID)
|
||||||
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, userID); err != nil {
|
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -789,6 +865,9 @@ func (a *Manager) AddUser(username, password string, role Role) error {
|
||||||
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
||||||
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
||||||
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
|
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
|
||||||
|
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
||||||
|
return ErrUserExists
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -916,12 +995,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var id, username, hash, role, prefs, syncTopic string
|
var id, username, hash, role, prefs, syncTopic string
|
||||||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
||||||
var messages, emails int64
|
var messages, emails, calls int64
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -936,6 +1015,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
Stats: &Stats{
|
Stats: &Stats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
|
Calls: calls,
|
||||||
},
|
},
|
||||||
Billing: &Billing{
|
Billing: &Billing{
|
||||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||||
|
@ -959,6 +1039,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
MessageLimit: messagesLimit.Int64,
|
MessageLimit: messagesLimit.Int64,
|
||||||
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailLimit: emailsLimit.Int64,
|
EmailLimit: emailsLimit.Int64,
|
||||||
|
CallLimit: callsLimit.Int64,
|
||||||
ReservationLimit: reservationsLimit.Int64,
|
ReservationLimit: reservationsLimit.Int64,
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||||
|
@ -1291,7 +1372,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||||
if tier.ID == "" {
|
if tier.ID == "" {
|
||||||
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
|
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
|
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -1299,7 +1380,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||||
|
|
||||||
// UpdateTier updates a tier's properties in the database
|
// UpdateTier updates a tier's properties in the database
|
||||||
func (a *Manager) UpdateTier(tier *Tier) error {
|
func (a *Manager) UpdateTier(tier *Tier) error {
|
||||||
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
|
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -1368,11 +1449,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||||
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||||
var id, code, name string
|
var id, code, name string
|
||||||
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
|
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrTierNotFound
|
return nil, ErrTierNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -1385,6 +1466,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||||
MessageLimit: messagesLimit.Int64,
|
MessageLimit: messagesLimit.Int64,
|
||||||
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailLimit: emailsLimit.Int64,
|
EmailLimit: emailsLimit.Int64,
|
||||||
|
CallLimit: callsLimit.Int64,
|
||||||
ReservationLimit: reservationsLimit.Int64,
|
ReservationLimit: reservationsLimit.Int64,
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||||
|
@ -1527,6 +1609,22 @@ func migrateFrom2(db *sql.DB) error {
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateFrom3(db *sql.DB) error {
|
||||||
|
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func nullString(s string) sql.NullString {
|
func nullString(s string) sql.NullString {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
|
|
|
@ -893,6 +893,44 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
|
||||||
require.Nil(t, a.ResetTier("phil"))
|
require.Nil(t, a.ResetTier("phil"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
||||||
|
|
||||||
|
phoneNumbers, err := a.PhoneNumbers(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(phoneNumbers))
|
||||||
|
require.Equal(t, "+1234567890", phoneNumbers[0])
|
||||||
|
|
||||||
|
require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890"))
|
||||||
|
phoneNumbers, err = a.PhoneNumbers(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(phoneNumbers))
|
||||||
|
|
||||||
|
// Paranoia check: We do NOT want to keep phone numbers in there
|
||||||
|
rows, err := a.db.Query(`SELECT * FROM user_phone`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.False(t, rows.Next())
|
||||||
|
require.Nil(t, rows.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
||||||
|
require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
filename := filepath.Join(t.TempDir(), "user.db")
|
filename := filepath.Join(t.TempDir(), "user.db")
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
|
|
@ -86,6 +86,7 @@ type Tier struct {
|
||||||
MessageLimit int64 // Daily message limit
|
MessageLimit int64 // Daily message limit
|
||||||
MessageExpiryDuration time.Duration // Cache duration for messages
|
MessageExpiryDuration time.Duration // Cache duration for messages
|
||||||
EmailLimit int64 // Daily email limit
|
EmailLimit int64 // Daily email limit
|
||||||
|
CallLimit int64 // Daily phone call limit
|
||||||
ReservationLimit int64 // Number of topic reservations allowed by user
|
ReservationLimit int64 // Number of topic reservations allowed by user
|
||||||
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
||||||
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
||||||
|
@ -131,6 +132,7 @@ type NotificationPrefs struct {
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Messages int64
|
Messages int64
|
||||||
Emails int64
|
Emails int64
|
||||||
|
Calls int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Billing is a struct holding a user's billing information
|
// Billing is a struct holding a user's billing information
|
||||||
|
@ -276,7 +278,10 @@ var (
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrUserExists = errors.New("user already exists")
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
ErrTokenNotFound = errors.New("token not found")
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
|
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
|
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||||
)
|
)
|
||||||
|
|
|
@ -3134,14 +3134,14 @@
|
||||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||||
},
|
},
|
||||||
"node_modules/@mui/base": {
|
"node_modules/@mui/base": {
|
||||||
"version": "5.0.0-beta.0",
|
"version": "5.0.0-beta.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.1.tgz",
|
||||||
"integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==",
|
"integrity": "sha512-xrkDCeu3JQE+JjJUnJnOrdQJMXwKhbV4AW+FRjMIj5i9cHK3BAuatG/iqbf1M+jklVWLk0KdbgioKwK+03aYbA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.21.0",
|
||||||
"@emotion/is-prop-valid": "^1.2.0",
|
"@emotion/is-prop-valid": "^1.2.0",
|
||||||
"@mui/types": "^7.2.4",
|
"@mui/types": "^7.2.4",
|
||||||
"@mui/utils": "^5.12.3",
|
"@mui/utils": "^5.13.1",
|
||||||
"@popperjs/core": "^2.11.7",
|
"@popperjs/core": "^2.11.7",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
@ -3166,9 +3166,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "5.13.0",
|
"version": "5.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.1.tgz",
|
||||||
"integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==",
|
"integrity": "sha512-qDHtNDO72NcBQMhaWBt9EZMvNiO+OXjPg5Sdk/6LgRDw6Zr3HdEZ5n2FJ/qtYsaT/okGyCuQavQkcZCOCEVf/g==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/mui"
|
"url": "https://opencollective.com/mui"
|
||||||
|
@ -3200,16 +3200,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "5.13.0",
|
"version": "5.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz",
|
||||||
"integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==",
|
"integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.21.0",
|
||||||
"@mui/base": "5.0.0-beta.0",
|
"@mui/base": "5.0.0-beta.1",
|
||||||
"@mui/core-downloads-tracker": "^5.13.0",
|
"@mui/core-downloads-tracker": "^5.13.1",
|
||||||
"@mui/system": "^5.12.3",
|
"@mui/system": "^5.13.1",
|
||||||
"@mui/types": "^7.2.4",
|
"@mui/types": "^7.2.4",
|
||||||
"@mui/utils": "^5.12.3",
|
"@mui/utils": "^5.13.1",
|
||||||
"@types/react-transition-group": "^4.4.6",
|
"@types/react-transition-group": "^4.4.6",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"csstype": "^3.1.2",
|
"csstype": "^3.1.2",
|
||||||
|
@ -3244,12 +3244,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "5.12.3",
|
"version": "5.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz",
|
||||||
"integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==",
|
"integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.21.0",
|
||||||
"@mui/utils": "^5.12.3",
|
"@mui/utils": "^5.13.1",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -3301,15 +3301,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "5.12.3",
|
"version": "5.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz",
|
||||||
"integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==",
|
"integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.21.0",
|
||||||
"@mui/private-theming": "^5.12.3",
|
"@mui/private-theming": "^5.13.1",
|
||||||
"@mui/styled-engine": "^5.12.3",
|
"@mui/styled-engine": "^5.12.3",
|
||||||
"@mui/types": "^7.2.4",
|
"@mui/types": "^7.2.4",
|
||||||
"@mui/utils": "^5.12.3",
|
"@mui/utils": "^5.13.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"csstype": "^3.1.2",
|
"csstype": "^3.1.2",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
|
@ -3353,13 +3353,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "5.12.3",
|
"version": "5.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz",
|
||||||
"integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==",
|
"integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.21.0",
|
||||||
"@types/prop-types": "^15.7.5",
|
"@types/prop-types": "^15.7.5",
|
||||||
"@types/react-is": "^16.7.1 || ^17.0.0",
|
"@types/react-is": "^18.2.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react-is": "^18.2.0"
|
"react-is": "^18.2.0"
|
||||||
},
|
},
|
||||||
|
@ -4016,9 +4016,9 @@
|
||||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
|
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.1.4",
|
"version": "20.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz",
|
||||||
"integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q=="
|
"integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/parse-json": {
|
"node_modules/@types/parse-json": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
|
@ -4061,21 +4061,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-is": {
|
"node_modules/@types/react-is": {
|
||||||
"version": "17.0.4",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz",
|
||||||
"integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==",
|
"integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^17"
|
"@types/react": "*"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react-is/node_modules/@types/react": {
|
|
||||||
"version": "17.0.59",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.59.tgz",
|
|
||||||
"integrity": "sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/prop-types": "*",
|
|
||||||
"@types/scheduler": "*",
|
|
||||||
"csstype": "^3.0.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-transition-group": {
|
"node_modules/@types/react-transition-group": {
|
||||||
|
@ -4175,14 +4165,14 @@
|
||||||
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
|
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz",
|
||||||
"integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==",
|
"integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.4.0",
|
"@eslint-community/regexpp": "^4.4.0",
|
||||||
"@typescript-eslint/scope-manager": "5.59.5",
|
"@typescript-eslint/scope-manager": "5.59.6",
|
||||||
"@typescript-eslint/type-utils": "5.59.5",
|
"@typescript-eslint/type-utils": "5.59.6",
|
||||||
"@typescript-eslint/utils": "5.59.5",
|
"@typescript-eslint/utils": "5.59.6",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"grapheme-splitter": "^1.0.4",
|
"grapheme-splitter": "^1.0.4",
|
||||||
"ignore": "^5.2.0",
|
"ignore": "^5.2.0",
|
||||||
|
@ -4208,11 +4198,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/experimental-utils": {
|
"node_modules/@typescript-eslint/experimental-utils": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz",
|
||||||
"integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==",
|
"integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/utils": "5.59.5"
|
"@typescript-eslint/utils": "5.59.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
@ -4226,13 +4216,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz",
|
||||||
"integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==",
|
"integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "5.59.5",
|
"@typescript-eslint/scope-manager": "5.59.6",
|
||||||
"@typescript-eslint/types": "5.59.5",
|
"@typescript-eslint/types": "5.59.6",
|
||||||
"@typescript-eslint/typescript-estree": "5.59.5",
|
"@typescript-eslint/typescript-estree": "5.59.6",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -4252,12 +4242,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz",
|
||||||
"integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==",
|
"integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.59.5",
|
"@typescript-eslint/types": "5.59.6",
|
||||||
"@typescript-eslint/visitor-keys": "5.59.5"
|
"@typescript-eslint/visitor-keys": "5.59.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
@ -4268,12 +4258,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz",
|
||||||
"integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==",
|
"integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/typescript-estree": "5.59.5",
|
"@typescript-eslint/typescript-estree": "5.59.6",
|
||||||
"@typescript-eslint/utils": "5.59.5",
|
"@typescript-eslint/utils": "5.59.6",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"tsutils": "^3.21.0"
|
"tsutils": "^3.21.0"
|
||||||
},
|
},
|
||||||
|
@ -4294,9 +4284,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz",
|
||||||
"integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==",
|
"integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
},
|
},
|
||||||
|
@ -4306,12 +4296,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz",
|
||||||
"integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==",
|
"integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.59.5",
|
"@typescript-eslint/types": "5.59.6",
|
||||||
"@typescript-eslint/visitor-keys": "5.59.5",
|
"@typescript-eslint/visitor-keys": "5.59.6",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"globby": "^11.1.0",
|
"globby": "^11.1.0",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
|
@ -4332,16 +4322,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz",
|
||||||
"integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==",
|
"integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@types/json-schema": "^7.0.9",
|
"@types/json-schema": "^7.0.9",
|
||||||
"@types/semver": "^7.3.12",
|
"@types/semver": "^7.3.12",
|
||||||
"@typescript-eslint/scope-manager": "5.59.5",
|
"@typescript-eslint/scope-manager": "5.59.6",
|
||||||
"@typescript-eslint/types": "5.59.5",
|
"@typescript-eslint/types": "5.59.6",
|
||||||
"@typescript-eslint/typescript-estree": "5.59.5",
|
"@typescript-eslint/typescript-estree": "5.59.6",
|
||||||
"eslint-scope": "^5.1.1",
|
"eslint-scope": "^5.1.1",
|
||||||
"semver": "^7.3.7"
|
"semver": "^7.3.7"
|
||||||
},
|
},
|
||||||
|
@ -4377,11 +4367,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "5.59.5",
|
"version": "5.59.6",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz",
|
||||||
"integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==",
|
"integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "5.59.5",
|
"@typescript-eslint/types": "5.59.6",
|
||||||
"eslint-visitor-keys": "^3.3.0"
|
"eslint-visitor-keys": "^3.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -4956,9 +4946,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axe-core": {
|
"node_modules/axe-core": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz",
|
||||||
"integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==",
|
"integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
|
@ -5511,9 +5501,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001487",
|
"version": "1.0.30001488",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
|
||||||
"integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==",
|
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
@ -6749,9 +6739,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.394",
|
"version": "1.4.397",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz",
|
||||||
"integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw=="
|
"integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q=="
|
||||||
},
|
},
|
||||||
"node_modules/emittery": {
|
"node_modules/emittery": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
|
@ -9146,9 +9136,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz",
|
||||||
"integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==",
|
"integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has": "^1.0.3"
|
"has": "^1.0.3"
|
||||||
},
|
},
|
||||||
|
@ -13879,9 +13869,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-selector-parser": {
|
"node_modules/postcss-selector-parser": {
|
||||||
"version": "6.0.12",
|
"version": "6.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
|
||||||
"integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==",
|
"integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
|
@ -15976,9 +15966,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/terser": {
|
"node_modules/terser": {
|
||||||
"version": "5.17.3",
|
"version": "5.17.4",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz",
|
||||||
"integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==",
|
"integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.2",
|
"@jridgewell/source-map": "^0.3.2",
|
||||||
"acorn": "^8.5.0",
|
"acorn": "^8.5.0",
|
||||||
|
@ -16272,16 +16262,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.0.4",
|
"version": "4.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20"
|
"node": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
|
|
|
@ -12,6 +12,8 @@ var config = {
|
||||||
enable_signup: true,
|
enable_signup: true,
|
||||||
enable_payments: true,
|
enable_payments: true,
|
||||||
enable_reservations: true,
|
enable_reservations: true,
|
||||||
|
enable_emails: true,
|
||||||
|
enable_calls: true,
|
||||||
billing_contact: "",
|
billing_contact: "",
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||||
};
|
};
|
||||||
|
|
|
@ -152,7 +152,7 @@
|
||||||
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
||||||
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
||||||
"subscribe_dialog_login_button_back": "العودة",
|
"common_back": "العودة",
|
||||||
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
||||||
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
||||||
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
||||||
|
@ -225,7 +225,7 @@
|
||||||
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
|
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
|
||||||
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
|
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
|
||||||
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
|
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
|
||||||
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
|
"common_copy_to_clipboard": "انسخ إلى الحافظة",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
|
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
|
||||||
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
|
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
|
||||||
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
|
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
|
||||||
|
|
|
@ -104,7 +104,7 @@
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
|
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
|
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
|
||||||
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
|
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
|
||||||
"subscribe_dialog_login_button_back": "Назад",
|
"common_back": "Назад",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Отказ",
|
"subscribe_dialog_subscribe_button_cancel": "Отказ",
|
||||||
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
|
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
|
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
||||||
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
||||||
"subscribe_dialog_login_password_label": "Heslo",
|
"subscribe_dialog_login_password_label": "Heslo",
|
||||||
"subscribe_dialog_login_button_back": "Zpět",
|
"common_back": "Zpět",
|
||||||
"subscribe_dialog_login_button_login": "Přihlásit se",
|
"subscribe_dialog_login_button_login": "Přihlásit se",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonymně",
|
"subscribe_dialog_error_user_anonymous": "anonymně",
|
||||||
|
@ -305,7 +305,7 @@
|
||||||
"account_tokens_table_expires_header": "Vyprší",
|
"account_tokens_table_expires_header": "Vyprší",
|
||||||
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
||||||
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
||||||
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
|
"common_copy_to_clipboard": "Kopírování do schránky",
|
||||||
"account_tokens_table_label_header": "Popisek",
|
"account_tokens_table_label_header": "Popisek",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
||||||
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
"publish_dialog_delay_label": "Forsinkelse",
|
"publish_dialog_delay_label": "Forsinkelse",
|
||||||
"publish_dialog_button_send": "Send",
|
"publish_dialog_button_send": "Send",
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
|
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
|
||||||
"subscribe_dialog_login_button_back": "Tilbage",
|
"common_back": "Tilbage",
|
||||||
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
|
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
|
||||||
"account_basics_title": "Konto",
|
"account_basics_title": "Konto",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
|
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
|
||||||
|
@ -209,7 +209,7 @@
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
|
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
|
||||||
"account_basics_tier_upgrade_button": "Opgrader til Pro",
|
"account_basics_tier_upgrade_button": "Opgrader til Pro",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
|
||||||
"account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder",
|
"common_copy_to_clipboard": "Kopier til udklipsholder",
|
||||||
"prefs_reservations_edit_button": "Rediger emneadgang",
|
"prefs_reservations_edit_button": "Rediger emneadgang",
|
||||||
"account_upgrade_dialog_title": "Skift kontoniveau",
|
"account_upgrade_dialog_title": "Skift kontoniveau",
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
|
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
|
||||||
|
|
|
@ -94,7 +94,7 @@
|
||||||
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
|
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
|
||||||
"prefs_appearance_title": "Darstellung",
|
"prefs_appearance_title": "Darstellung",
|
||||||
"subscribe_dialog_login_password_label": "Kennwort",
|
"subscribe_dialog_login_password_label": "Kennwort",
|
||||||
"subscribe_dialog_login_button_back": "Zurück",
|
"common_back": "Zurück",
|
||||||
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
|
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
|
||||||
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
|
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
|
||||||
"publish_dialog_chip_topic_label": "Thema ändern",
|
"publish_dialog_chip_topic_label": "Thema ändern",
|
||||||
|
@ -284,7 +284,7 @@
|
||||||
"account_tokens_table_expires_header": "Verfällt",
|
"account_tokens_table_expires_header": "Verfällt",
|
||||||
"account_tokens_table_never_expires": "Verfällt nie",
|
"account_tokens_table_never_expires": "Verfällt nie",
|
||||||
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
|
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
|
||||||
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
|
"common_copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||||
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
|
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
|
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
|
||||||
"account_tokens_table_create_token_button": "Access-Token erzeugen",
|
"account_tokens_table_create_token_button": "Access-Token erzeugen",
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
"common_cancel": "Cancel",
|
"common_cancel": "Cancel",
|
||||||
"common_save": "Save",
|
"common_save": "Save",
|
||||||
"common_add": "Add",
|
"common_add": "Add",
|
||||||
|
"common_back": "Back",
|
||||||
|
"common_copy_to_clipboard": "Copy to clipboard",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
|
@ -127,6 +129,9 @@
|
||||||
"publish_dialog_email_label": "Email",
|
"publish_dialog_email_label": "Email",
|
||||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||||
"publish_dialog_email_reset": "Remove email forward",
|
"publish_dialog_email_reset": "Remove email forward",
|
||||||
|
"publish_dialog_call_label": "Phone call",
|
||||||
|
"publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444, or 'yes'",
|
||||||
|
"publish_dialog_call_reset": "Remove phone call",
|
||||||
"publish_dialog_attach_label": "Attachment URL",
|
"publish_dialog_attach_label": "Attachment URL",
|
||||||
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
||||||
"publish_dialog_attach_reset": "Remove attachment URL",
|
"publish_dialog_attach_reset": "Remove attachment URL",
|
||||||
|
@ -138,6 +143,7 @@
|
||||||
"publish_dialog_other_features": "Other features:",
|
"publish_dialog_other_features": "Other features:",
|
||||||
"publish_dialog_chip_click_label": "Click URL",
|
"publish_dialog_chip_click_label": "Click URL",
|
||||||
"publish_dialog_chip_email_label": "Forward to email",
|
"publish_dialog_chip_email_label": "Forward to email",
|
||||||
|
"publish_dialog_chip_call_label": "Phone call",
|
||||||
"publish_dialog_chip_attach_url_label": "Attach file by URL",
|
"publish_dialog_chip_attach_url_label": "Attach file by URL",
|
||||||
"publish_dialog_chip_attach_file_label": "Attach local file",
|
"publish_dialog_chip_attach_file_label": "Attach local file",
|
||||||
"publish_dialog_chip_delay_label": "Delay delivery",
|
"publish_dialog_chip_delay_label": "Delay delivery",
|
||||||
|
@ -165,7 +171,6 @@
|
||||||
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
|
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
|
||||||
"subscribe_dialog_login_username_label": "Username, e.g. phil",
|
"subscribe_dialog_login_username_label": "Username, e.g. phil",
|
||||||
"subscribe_dialog_login_password_label": "Password",
|
"subscribe_dialog_login_password_label": "Password",
|
||||||
"subscribe_dialog_login_button_back": "Back",
|
|
||||||
"subscribe_dialog_login_button_login": "Login",
|
"subscribe_dialog_login_button_login": "Login",
|
||||||
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
|
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
|
||||||
|
@ -182,6 +187,21 @@
|
||||||
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
||||||
"account_basics_password_dialog_button_submit": "Change password",
|
"account_basics_password_dialog_button_submit": "Change password",
|
||||||
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
||||||
|
"account_basics_phone_numbers_title": "Phone numbers",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.",
|
||||||
|
"account_basics_phone_numbers_description": "For phone call notifications",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard",
|
||||||
|
"account_basics_phone_numbers_dialog_title": "Add phone number",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "Phone number",
|
||||||
|
"account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "Call me",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "Verification code",
|
||||||
|
"account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
|
@ -203,6 +223,8 @@
|
||||||
"account_basics_tier_manage_billing_button": "Manage billing",
|
"account_basics_tier_manage_billing_button": "Manage billing",
|
||||||
"account_usage_messages_title": "Published messages",
|
"account_usage_messages_title": "Published messages",
|
||||||
"account_usage_emails_title": "Emails sent",
|
"account_usage_emails_title": "Emails sent",
|
||||||
|
"account_usage_calls_title": "Phone calls made",
|
||||||
|
"account_usage_calls_none": "No phone calls can be made with this account",
|
||||||
"account_usage_reservations_title": "Reserved topics",
|
"account_usage_reservations_title": "Reserved topics",
|
||||||
"account_usage_reservations_none": "No reserved topics for this account",
|
"account_usage_reservations_none": "No reserved topics for this account",
|
||||||
"account_usage_attachment_storage_title": "Attachment storage",
|
"account_usage_attachment_storage_title": "Attachment storage",
|
||||||
|
@ -232,6 +254,9 @@
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
|
||||||
|
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||||
|
@ -254,7 +279,6 @@
|
||||||
"account_tokens_table_expires_header": "Expires",
|
"account_tokens_table_expires_header": "Expires",
|
||||||
"account_tokens_table_never_expires": "Never expires",
|
"account_tokens_table_never_expires": "Never expires",
|
||||||
"account_tokens_table_current_session": "Current browser session",
|
"account_tokens_table_current_session": "Current browser session",
|
||||||
"account_tokens_table_copy_to_clipboard": "Copy to clipboard",
|
|
||||||
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
||||||
"account_tokens_table_create_token_button": "Create access token",
|
"account_tokens_table_create_token_button": "Create access token",
|
||||||
|
|
|
@ -81,7 +81,7 @@
|
||||||
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
|
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
|
||||||
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
|
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
|
||||||
"subscribe_dialog_login_password_label": "Contraseña",
|
"subscribe_dialog_login_password_label": "Contraseña",
|
||||||
"subscribe_dialog_login_button_back": "Volver",
|
"common_back": "Volver",
|
||||||
"subscribe_dialog_login_button_login": "Iniciar sesión",
|
"subscribe_dialog_login_button_login": "Iniciar sesión",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
|
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
|
@ -257,7 +257,7 @@
|
||||||
"account_tokens_table_expires_header": "Expira",
|
"account_tokens_table_expires_header": "Expira",
|
||||||
"account_tokens_table_never_expires": "Nunca expira",
|
"account_tokens_table_never_expires": "Nunca expira",
|
||||||
"account_tokens_table_current_session": "Sesión del navegador actual",
|
"account_tokens_table_current_session": "Sesión del navegador actual",
|
||||||
"account_tokens_table_copy_to_clipboard": "Copiar al portapapeles",
|
"common_copy_to_clipboard": "Copiar al portapapeles",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
|
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
|
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
|
||||||
"account_tokens_table_create_token_button": "Crear token de acceso",
|
"account_tokens_table_create_token_button": "Crear token de acceso",
|
||||||
|
|
|
@ -106,7 +106,7 @@
|
||||||
"prefs_notifications_title": "Notifications",
|
"prefs_notifications_title": "Notifications",
|
||||||
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
||||||
"prefs_users_add_button": "Ajouter un utilisateur",
|
"prefs_users_add_button": "Ajouter un utilisateur",
|
||||||
"subscribe_dialog_login_button_back": "Retour",
|
"common_back": "Retour",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonyme",
|
"subscribe_dialog_error_user_anonymous": "anonyme",
|
||||||
"prefs_notifications_sound_no_sound": "Aucun son",
|
"prefs_notifications_sound_no_sound": "Aucun son",
|
||||||
"prefs_notifications_min_priority_title": "Priorité minimum",
|
"prefs_notifications_min_priority_title": "Priorité minimum",
|
||||||
|
@ -293,7 +293,7 @@
|
||||||
"account_tokens_table_expires_header": "Expire",
|
"account_tokens_table_expires_header": "Expire",
|
||||||
"account_tokens_table_never_expires": "N'expire jamais",
|
"account_tokens_table_never_expires": "N'expire jamais",
|
||||||
"account_tokens_table_current_session": "Session de navigation actuelle",
|
"account_tokens_table_current_session": "Session de navigation actuelle",
|
||||||
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
|
"common_copy_to_clipboard": "Copier dans le presse-papier",
|
||||||
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
||||||
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
||||||
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
||||||
|
|
|
@ -84,7 +84,7 @@
|
||||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
||||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
||||||
"subscribe_dialog_login_password_label": "Jelszó",
|
"subscribe_dialog_login_password_label": "Jelszó",
|
||||||
"subscribe_dialog_login_button_back": "Vissza",
|
"common_back": "Vissza",
|
||||||
"subscribe_dialog_login_button_login": "Belépés",
|
"subscribe_dialog_login_button_login": "Belépés",
|
||||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||||
|
|
|
@ -116,7 +116,7 @@
|
||||||
"common_save": "Simpan",
|
"common_save": "Simpan",
|
||||||
"prefs_appearance_title": "Tampilan",
|
"prefs_appearance_title": "Tampilan",
|
||||||
"subscribe_dialog_login_password_label": "Kata sandi",
|
"subscribe_dialog_login_password_label": "Kata sandi",
|
||||||
"subscribe_dialog_login_button_back": "Kembali",
|
"common_back": "Kembali",
|
||||||
"prefs_notifications_sound_title": "Suara notifikasi",
|
"prefs_notifications_sound_title": "Suara notifikasi",
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
|
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
|
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
|
||||||
|
@ -278,7 +278,7 @@
|
||||||
"account_tokens_table_expires_header": "Kedaluwarsa",
|
"account_tokens_table_expires_header": "Kedaluwarsa",
|
||||||
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
|
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
|
||||||
"account_tokens_table_current_session": "Sesi peramban saat ini",
|
"account_tokens_table_current_session": "Sesi peramban saat ini",
|
||||||
"account_tokens_table_copy_to_clipboard": "Salin ke papan klip",
|
"common_copy_to_clipboard": "Salin ke papan klip",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token akses disalin",
|
"account_tokens_table_copied_to_clipboard": "Token akses disalin",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
|
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
|
||||||
"account_tokens_table_create_token_button": "Buat token akses",
|
"account_tokens_table_create_token_button": "Buat token akses",
|
||||||
|
|
|
@ -178,7 +178,7 @@
|
||||||
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
||||||
"prefs_notifications_min_priority_title": "Priorità minima",
|
"prefs_notifications_min_priority_title": "Priorità minima",
|
||||||
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
||||||
"subscribe_dialog_login_button_back": "Indietro",
|
"common_back": "Indietro",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
||||||
"prefs_notifications_title": "Notifiche",
|
"prefs_notifications_title": "Notifiche",
|
||||||
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
||||||
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
|
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
|
||||||
"subscribe_dialog_login_password_label": "パスワード",
|
"subscribe_dialog_login_password_label": "パスワード",
|
||||||
"subscribe_dialog_login_button_back": "戻る",
|
"common_back": "戻る",
|
||||||
"subscribe_dialog_login_button_login": "ログイン",
|
"subscribe_dialog_login_button_login": "ログイン",
|
||||||
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
|
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
|
||||||
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
|
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
|
||||||
|
@ -258,7 +258,7 @@
|
||||||
"account_tokens_table_expires_header": "期限",
|
"account_tokens_table_expires_header": "期限",
|
||||||
"account_tokens_table_never_expires": "無期限",
|
"account_tokens_table_never_expires": "無期限",
|
||||||
"account_tokens_table_current_session": "現在のブラウザセッション",
|
"account_tokens_table_current_session": "現在のブラウザセッション",
|
||||||
"account_tokens_table_copy_to_clipboard": "クリップボードにコピー",
|
"common_copy_to_clipboard": "クリップボードにコピー",
|
||||||
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
|
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
|
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
|
||||||
"account_tokens_table_create_token_button": "アクセストークンを生成",
|
"account_tokens_table_create_token_button": "アクセストークンを生成",
|
||||||
|
|
|
@ -93,7 +93,7 @@
|
||||||
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
|
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
|
||||||
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
|
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
|
||||||
"subscribe_dialog_login_password_label": "비밀번호",
|
"subscribe_dialog_login_password_label": "비밀번호",
|
||||||
"subscribe_dialog_login_button_back": "뒤로가기",
|
"common_back": "뒤로가기",
|
||||||
"subscribe_dialog_login_button_login": "로그인",
|
"subscribe_dialog_login_button_login": "로그인",
|
||||||
"prefs_notifications_title": "알림",
|
"prefs_notifications_title": "알림",
|
||||||
"prefs_notifications_sound_title": "알림 효과음",
|
"prefs_notifications_sound_title": "알림 효과음",
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
|
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
|
||||||
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
|
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
|
||||||
"priority_min": "min.",
|
"priority_min": "min.",
|
||||||
"subscribe_dialog_login_button_back": "Tilbake",
|
"common_back": "Tilbake",
|
||||||
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
||||||
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
||||||
"common_cancel": "Avbryt",
|
"common_cancel": "Avbryt",
|
||||||
|
|
|
@ -140,7 +140,7 @@
|
||||||
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
|
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
|
||||||
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
|
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
|
||||||
"subscribe_dialog_login_password_label": "Wachtwoord",
|
"subscribe_dialog_login_password_label": "Wachtwoord",
|
||||||
"subscribe_dialog_login_button_back": "Terug",
|
"common_back": "Terug",
|
||||||
"subscribe_dialog_login_button_login": "Aanmelden",
|
"subscribe_dialog_login_button_login": "Aanmelden",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
|
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
|
||||||
"subscribe_dialog_error_user_anonymous": "anoniem",
|
"subscribe_dialog_error_user_anonymous": "anoniem",
|
||||||
|
@ -331,7 +331,7 @@
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
|
"account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
|
||||||
"account_tokens_table_last_access_header": "Laatste toegang",
|
"account_tokens_table_last_access_header": "Laatste toegang",
|
||||||
"account_tokens_table_expires_header": "Verloopt op",
|
"account_tokens_table_expires_header": "Verloopt op",
|
||||||
"account_tokens_table_copy_to_clipboard": "Kopieer naar klembord",
|
"common_copy_to_clipboard": "Kopieer naar klembord",
|
||||||
"account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
|
"account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
|
||||||
"account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
|
"account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
|
||||||
"prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",
|
"prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",
|
||||||
|
|
|
@ -107,7 +107,7 @@
|
||||||
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
|
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
|
||||||
"subscribe_dialog_login_password_label": "Hasło",
|
"subscribe_dialog_login_password_label": "Hasło",
|
||||||
"publish_dialog_button_cancel": "Anuluj",
|
"publish_dialog_button_cancel": "Anuluj",
|
||||||
"subscribe_dialog_login_button_back": "Powrót",
|
"common_back": "Powrót",
|
||||||
"subscribe_dialog_login_button_login": "Zaloguj się",
|
"subscribe_dialog_login_button_login": "Zaloguj się",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
|
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||||
|
@ -253,7 +253,7 @@
|
||||||
"account_tokens_table_expires_header": "Termin ważności",
|
"account_tokens_table_expires_header": "Termin ważności",
|
||||||
"account_tokens_table_never_expires": "Bezterminowy",
|
"account_tokens_table_never_expires": "Bezterminowy",
|
||||||
"account_tokens_table_current_session": "Aktualna sesja przeglądarki",
|
"account_tokens_table_current_session": "Aktualna sesja przeglądarki",
|
||||||
"account_tokens_table_copy_to_clipboard": "Kopiuj do schowka",
|
"common_copy_to_clipboard": "Kopiuj do schowka",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token został skopiowany",
|
"account_tokens_table_copied_to_clipboard": "Token został skopiowany",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
|
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
|
||||||
"account_tokens_table_create_token_button": "Utwórz token dostępowy",
|
"account_tokens_table_create_token_button": "Utwórz token dostępowy",
|
||||||
|
|
|
@ -144,7 +144,7 @@
|
||||||
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
||||||
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
||||||
"subscribe_dialog_login_password_label": "Palavra-passe",
|
"subscribe_dialog_login_password_label": "Palavra-passe",
|
||||||
"subscribe_dialog_login_button_back": "Voltar",
|
"common_back": "Voltar",
|
||||||
"subscribe_dialog_login_button_login": "Autenticar",
|
"subscribe_dialog_login_button_login": "Autenticar",
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
"prefs_notifications_title": "Notificações",
|
"prefs_notifications_title": "Notificações",
|
||||||
|
|
|
@ -93,7 +93,7 @@
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
||||||
"subscribe_dialog_login_password_label": "Senha",
|
"subscribe_dialog_login_password_label": "Senha",
|
||||||
"subscribe_dialog_login_button_back": "Voltar",
|
"common_back": "Voltar",
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
||||||
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
||||||
"prefs_notifications_delete_after_title": "Apagar notificações",
|
"prefs_notifications_delete_after_title": "Apagar notificações",
|
||||||
|
|
|
@ -98,7 +98,7 @@
|
||||||
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
|
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
|
||||||
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
|
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
|
||||||
"subscribe_dialog_login_password_label": "Пароль",
|
"subscribe_dialog_login_password_label": "Пароль",
|
||||||
"subscribe_dialog_login_button_back": "Назад",
|
"common_back": "Назад",
|
||||||
"subscribe_dialog_login_button_login": "Войти",
|
"subscribe_dialog_login_button_login": "Войти",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
||||||
"subscribe_dialog_error_user_anonymous": "анонимный пользователь",
|
"subscribe_dialog_error_user_anonymous": "анонимный пользователь",
|
||||||
|
@ -206,7 +206,7 @@
|
||||||
"account_basics_tier_free": "Бесплатный",
|
"account_basics_tier_free": "Бесплатный",
|
||||||
"account_tokens_dialog_title_create": "Создать токен доступа",
|
"account_tokens_dialog_title_create": "Создать токен доступа",
|
||||||
"account_tokens_dialog_title_delete": "Удалить токен доступа",
|
"account_tokens_dialog_title_delete": "Удалить токен доступа",
|
||||||
"account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена",
|
"common_copy_to_clipboard": "Скопировать в буфер обмена",
|
||||||
"account_tokens_dialog_button_cancel": "Отмена",
|
"account_tokens_dialog_button_cancel": "Отмена",
|
||||||
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
|
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
|
||||||
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
|
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
|
||||||
|
|
|
@ -95,14 +95,14 @@
|
||||||
"publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
|
"publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
|
||||||
"publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
|
"publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
|
||||||
"publish_dialog_button_send": "Skicka",
|
"publish_dialog_button_send": "Skicka",
|
||||||
"subscribe_dialog_login_button_back": "Tillbaka",
|
"common_back": "Tillbaka",
|
||||||
"account_basics_tier_free": "Gratis",
|
"account_basics_tier_free": "Gratis",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
|
||||||
"account_delete_title": "Ta bort konto",
|
"account_delete_title": "Ta bort konto",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
|
||||||
"account_upgrade_dialog_button_cancel": "Avbryt",
|
"account_upgrade_dialog_button_cancel": "Avbryt",
|
||||||
"account_tokens_table_copy_to_clipboard": "Kopiera till urklipp",
|
"common_copy_to_clipboard": "Kopiera till urklipp",
|
||||||
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
|
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
|
||||||
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
|
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
|
||||||
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
|
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
|
||||||
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
|
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
|
||||||
"subscribe_dialog_login_password_label": "Parola",
|
"subscribe_dialog_login_password_label": "Parola",
|
||||||
"subscribe_dialog_login_button_back": "Geri",
|
"common_back": "Geri",
|
||||||
"subscribe_dialog_login_button_login": "Oturum aç",
|
"subscribe_dialog_login_button_login": "Oturum aç",
|
||||||
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
|
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||||
|
@ -268,7 +268,7 @@
|
||||||
"account_tokens_table_token_header": "Belirteç",
|
"account_tokens_table_token_header": "Belirteç",
|
||||||
"account_tokens_table_label_header": "Etiket",
|
"account_tokens_table_label_header": "Etiket",
|
||||||
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
|
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
|
||||||
"account_tokens_table_copy_to_clipboard": "Panoya kopyala",
|
"common_copy_to_clipboard": "Panoya kopyala",
|
||||||
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
|
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
|
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
|
||||||
"account_tokens_table_create_token_button": "Erişim belirteci oluştur",
|
"account_tokens_table_create_token_button": "Erişim belirteci oluştur",
|
||||||
|
|
|
@ -53,7 +53,7 @@
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
|
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "URL служби",
|
"subscribe_dialog_subscribe_base_url_label": "URL служби",
|
||||||
"subscribe_dialog_login_password_label": "Пароль",
|
"subscribe_dialog_login_password_label": "Пароль",
|
||||||
"subscribe_dialog_login_button_back": "Назад",
|
"common_back": "Назад",
|
||||||
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
|
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
|
||||||
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
|
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
|
||||||
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
|
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
|
||||||
|
|
|
@ -103,7 +103,7 @@
|
||||||
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
|
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
|
||||||
"subscribe_dialog_login_username_label": "用户名,例如 phil",
|
"subscribe_dialog_login_username_label": "用户名,例如 phil",
|
||||||
"subscribe_dialog_login_password_label": "密码",
|
"subscribe_dialog_login_password_label": "密码",
|
||||||
"subscribe_dialog_login_button_back": "返回",
|
"common_back": "返回",
|
||||||
"subscribe_dialog_login_button_login": "登录",
|
"subscribe_dialog_login_button_login": "登录",
|
||||||
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
|
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
|
||||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||||
|
@ -333,7 +333,7 @@
|
||||||
"account_tokens_table_expires_header": "过期",
|
"account_tokens_table_expires_header": "过期",
|
||||||
"account_tokens_table_never_expires": "永不过期",
|
"account_tokens_table_never_expires": "永不过期",
|
||||||
"account_tokens_table_current_session": "当前浏览器会话",
|
"account_tokens_table_current_session": "当前浏览器会话",
|
||||||
"account_tokens_table_copy_to_clipboard": "复制到剪贴板",
|
"common_copy_to_clipboard": "复制到剪贴板",
|
||||||
"account_tokens_table_copied_to_clipboard": "已复制访问令牌",
|
"account_tokens_table_copied_to_clipboard": "已复制访问令牌",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
|
"account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
|
||||||
"account_tokens_table_create_token_button": "创建访问令牌",
|
"account_tokens_table_create_token_button": "创建访问令牌",
|
||||||
|
|
|
@ -70,7 +70,7 @@
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
|
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
|
||||||
"emoji_picker_search_clear": "清除",
|
"emoji_picker_search_clear": "清除",
|
||||||
"subscribe_dialog_login_password_label": "密碼",
|
"subscribe_dialog_login_password_label": "密碼",
|
||||||
"subscribe_dialog_login_button_back": "返回",
|
"common_back": "返回",
|
||||||
"subscribe_dialog_login_button_login": "登入",
|
"subscribe_dialog_login_button_login": "登入",
|
||||||
"prefs_notifications_delete_after_never": "從不",
|
"prefs_notifications_delete_after_never": "從不",
|
||||||
"prefs_users_add_button": "新增使用者",
|
"prefs_users_add_button": "新增使用者",
|
||||||
|
|
|
@ -2,13 +2,15 @@ import {
|
||||||
accountBillingPortalUrl,
|
accountBillingPortalUrl,
|
||||||
accountBillingSubscriptionUrl,
|
accountBillingSubscriptionUrl,
|
||||||
accountPasswordUrl,
|
accountPasswordUrl,
|
||||||
|
accountPhoneUrl,
|
||||||
|
accountPhoneVerifyUrl,
|
||||||
accountReservationSingleUrl,
|
accountReservationSingleUrl,
|
||||||
accountReservationUrl,
|
accountReservationUrl,
|
||||||
accountSettingsUrl,
|
accountSettingsUrl,
|
||||||
accountSubscriptionSingleUrl,
|
|
||||||
accountSubscriptionUrl,
|
accountSubscriptionUrl,
|
||||||
accountTokenUrl,
|
accountTokenUrl,
|
||||||
accountUrl, maybeWithBearerAuth,
|
accountUrl,
|
||||||
|
maybeWithBearerAuth,
|
||||||
tiersUrl,
|
tiersUrl,
|
||||||
withBasicAuth,
|
withBasicAuth,
|
||||||
withBearerAuth
|
withBearerAuth
|
||||||
|
@ -18,7 +20,7 @@ import subscriptionManager from "./SubscriptionManager";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import routes from "../components/routes";
|
import routes from "../components/routes";
|
||||||
import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors";
|
import {fetchOrThrow, UnauthorizedError} from "./errors";
|
||||||
|
|
||||||
const delayMillis = 45000; // 45 seconds
|
const delayMillis = 45000; // 45 seconds
|
||||||
const intervalMillis = 900000; // 15 minutes
|
const intervalMillis = 900000; // 15 minutes
|
||||||
|
@ -299,6 +301,44 @@ class AccountApi {
|
||||||
return await response.json(); // May throw SyntaxError
|
return await response.json(); // May throw SyntaxError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async verifyPhoneNumber(phoneNumber, channel) {
|
||||||
|
const url = accountPhoneVerifyUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Sending phone verification ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: phoneNumber,
|
||||||
|
channel: channel
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPhoneNumber(phoneNumber, code) {
|
||||||
|
const url = accountPhoneUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: phoneNumber,
|
||||||
|
code: code
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePhoneNumber(phoneNumber, code) {
|
||||||
|
const url = accountPhoneUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Deleting phone number ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: phoneNumber
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sync() {
|
async sync() {
|
||||||
try {
|
try {
|
||||||
if (!session.token()) {
|
if (!session.token()) {
|
||||||
|
|
|
@ -27,6 +27,8 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva
|
||||||
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
||||||
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
||||||
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||||
|
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||||
|
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
|
@ -206,10 +208,12 @@ export const formatBytes = (bytes, decimals = 2) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatNumber = (n) => {
|
export const formatNumber = (n) => {
|
||||||
if (n % 1000 === 0) {
|
if (n === 0) {
|
||||||
|
return n;
|
||||||
|
} else if (n % 1000 === 0) {
|
||||||
return `${n/1000}k`;
|
return `${n/1000}k`;
|
||||||
}
|
}
|
||||||
return n;
|
return n.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatPrice = (n) => {
|
export const formatPrice = (n) => {
|
||||||
|
|
|
@ -4,10 +4,14 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Link,
|
Link,
|
||||||
Portal,
|
Portal,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
Stack,
|
Stack,
|
||||||
|
@ -51,6 +55,8 @@ import {ContentCopy, Public} from "@mui/icons-material";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
|
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
|
||||||
|
import {ProChip} from "./SubscriptionPopup";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
|
@ -79,6 +85,7 @@ const Basics = () => {
|
||||||
<PrefGroup>
|
<PrefGroup>
|
||||||
<Username/>
|
<Username/>
|
||||||
<ChangePassword/>
|
<ChangePassword/>
|
||||||
|
<PhoneNumbers/>
|
||||||
<AccountType/>
|
<AccountType/>
|
||||||
</PrefGroup>
|
</PrefGroup>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -319,6 +326,206 @@ const AccountType = () => {
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PhoneNumbers = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
|
const labelId = "prefPhoneNumbers";
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (phoneNumber) => {
|
||||||
|
navigator.clipboard.writeText(phoneNumber);
|
||||||
|
setSnackOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (phoneNumber) => {
|
||||||
|
try {
|
||||||
|
await accountApi.deletePhoneNumber(phoneNumber);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error deleting phone number`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.enable_calls) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account?.limits.calls === 0) {
|
||||||
|
return (
|
||||||
|
<Pref title={<>{t("account_basics_phone_numbers_title")}{config.enable_payments && <ProChip/>}</>} description={t("account_basics_phone_numbers_description")}>
|
||||||
|
<em>{t("account_usage_calls_none")}</em>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
||||||
|
<div aria-labelledby={labelId}>
|
||||||
|
{account?.phone_numbers?.map(phoneNumber =>
|
||||||
|
<Chip
|
||||||
|
label={
|
||||||
|
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||||
|
<span>{phoneNumber}</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleCopy(phoneNumber)}
|
||||||
|
onDelete={() => handleDelete(phoneNumber)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!account?.phone_numbers &&
|
||||||
|
<em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
|
||||||
|
}
|
||||||
|
<IconButton onClick={handleDialogOpen}><AddIcon/></IconButton>
|
||||||
|
</div>
|
||||||
|
<AddPhoneNumberDialog
|
||||||
|
key={`addPhoneNumberDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
<Portal>
|
||||||
|
<Snackbar
|
||||||
|
open={snackOpen}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setSnackOpen(false)}
|
||||||
|
message={t("account_basics_phone_numbers_copied_to_clipboard")}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddPhoneNumberDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
|
const [channel, setChannel] = useState("sms");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
const handleDialogSubmit = async () => {
|
||||||
|
if (!verificationCodeSent) {
|
||||||
|
await verifyPhone();
|
||||||
|
} else {
|
||||||
|
await checkVerifyPhone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (verificationCodeSent) {
|
||||||
|
setVerificationCodeSent(false);
|
||||||
|
setCode("");
|
||||||
|
} else {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyPhone = async () => {
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
await accountApi.verifyPhoneNumber(phoneNumber, channel);
|
||||||
|
setVerificationCodeSent(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error sending verification`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkVerifyPhone = async () => {
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
await accountApi.addPhoneNumber(phoneNumber, code);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error confirming verification`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{t("account_basics_phone_numbers_dialog_description")}
|
||||||
|
</DialogContentText>
|
||||||
|
{!verificationCodeSent &&
|
||||||
|
<div style={{display: "flex"}}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label={t("account_basics_phone_numbers_dialog_number_label")}
|
||||||
|
aria-label={t("account_basics_phone_numbers_dialog_number_label")}
|
||||||
|
placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
|
||||||
|
type="tel"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={ev => setPhoneNumber(ev.target.value)}
|
||||||
|
inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }}
|
||||||
|
variant="standard"
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
/>
|
||||||
|
<FormControl sx={{ flexWrap: "nowrap" }}>
|
||||||
|
<RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
|
||||||
|
<FormControlLabel value="sms" control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} />
|
||||||
|
<FormControlLabel value="call" control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{verificationCodeSent &&
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label={t("account_basics_phone_numbers_dialog_code_label")}
|
||||||
|
aria-label={t("account_basics_phone_numbers_dialog_code_label")}
|
||||||
|
placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={ev => setCode(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
|
||||||
|
{!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
|
||||||
|
{!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
|
||||||
|
{verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const Stats = () => {
|
const Stats = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
|
@ -337,23 +544,18 @@ const Stats = () => {
|
||||||
{t("account_usage_title")}
|
{t("account_usage_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<PrefGroup>
|
<PrefGroup>
|
||||||
<Pref title={t("account_usage_reservations_title")}>
|
|
||||||
{(account.role === Role.ADMIN || account.limits.reservations > 0) &&
|
{(account.role === Role.ADMIN || account.limits.reservations > 0) &&
|
||||||
<>
|
<Pref title={t("account_usage_reservations_title")}>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
<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}) : t("account_usage_unlimited")}</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>
|
</div>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
}
|
|
||||||
{account.role === Role.USER && account.limits.reservations === 0 &&
|
|
||||||
<em>{t("account_usage_reservations_none")}</em>
|
|
||||||
}
|
|
||||||
</Pref>
|
</Pref>
|
||||||
|
}
|
||||||
<Pref title={
|
<Pref title={
|
||||||
<>
|
<>
|
||||||
{t("account_usage_messages_title")}
|
{t("account_usage_messages_title")}
|
||||||
|
@ -361,14 +563,15 @@ const Stats = () => {
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
|
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages.toLocaleString()}</Typography>
|
||||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||||
</div>
|
</div>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
|
value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
|
{config.enable_emails &&
|
||||||
<Pref title={
|
<Pref title={
|
||||||
<>
|
<>
|
||||||
{t("account_usage_emails_title")}
|
{t("account_usage_emails_title")}
|
||||||
|
@ -376,14 +579,32 @@ const Stats = () => {
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
|
<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 }) : t("account_usage_unlimited")}</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>
|
</div>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
|
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
|
}
|
||||||
|
{config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) &&
|
||||||
|
<Pref title={
|
||||||
|
<>
|
||||||
|
{t("account_usage_calls_title")}
|
||||||
|
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
<Typography variant="body2" sx={{float: "left"}}>{account.stats.calls.toLocaleString()}</Typography>
|
||||||
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
}
|
||||||
<Pref
|
<Pref
|
||||||
alignTop
|
alignTop
|
||||||
title={t("account_usage_attachment_storage_title")}
|
title={t("account_usage_attachment_storage_title")}
|
||||||
|
@ -404,6 +625,16 @@ const Stats = () => {
|
||||||
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
|
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
|
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 &&
|
||||||
|
<Pref title={<>{t("account_usage_reservations_title")}{config.enable_payments && <ProChip/>}</>}>
|
||||||
|
<em>{t("account_usage_reservations_none")}</em>
|
||||||
|
</Pref>
|
||||||
|
}
|
||||||
|
{config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
|
||||||
|
<Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
|
||||||
|
<em>{t("account_usage_calls_none")}</em>
|
||||||
|
</Pref>
|
||||||
|
}
|
||||||
</PrefGroup>
|
</PrefGroup>
|
||||||
{account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
|
{account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
|
@ -535,7 +766,7 @@ const TokensTable = (props) => {
|
||||||
<span>
|
<span>
|
||||||
<span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span>
|
<span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span>
|
||||||
...
|
...
|
||||||
<Tooltip title={t("account_tokens_table_copy_to_clipboard")} placement="right">
|
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||||
<IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
|
<IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -45,6 +45,7 @@ const PublishDialog = (props) => {
|
||||||
const [filename, setFilename] = useState("");
|
const [filename, setFilename] = useState("");
|
||||||
const [filenameEdited, setFilenameEdited] = useState(false);
|
const [filenameEdited, setFilenameEdited] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [call, setCall] = useState("");
|
||||||
const [delay, setDelay] = useState("");
|
const [delay, setDelay] = useState("");
|
||||||
const [publishAnother, setPublishAnother] = useState(false);
|
const [publishAnother, setPublishAnother] = useState(false);
|
||||||
|
|
||||||
|
@ -52,6 +53,7 @@ const PublishDialog = (props) => {
|
||||||
const [showClickUrl, setShowClickUrl] = useState(false);
|
const [showClickUrl, setShowClickUrl] = useState(false);
|
||||||
const [showAttachUrl, setShowAttachUrl] = useState(false);
|
const [showAttachUrl, setShowAttachUrl] = useState(false);
|
||||||
const [showEmail, setShowEmail] = useState(false);
|
const [showEmail, setShowEmail] = useState(false);
|
||||||
|
const [showCall, setShowCall] = useState(false);
|
||||||
const [showDelay, setShowDelay] = useState(false);
|
const [showDelay, setShowDelay] = useState(false);
|
||||||
|
|
||||||
const showAttachFile = !!attachFile && !showAttachUrl;
|
const showAttachFile = !!attachFile && !showAttachUrl;
|
||||||
|
@ -124,6 +126,9 @@ const PublishDialog = (props) => {
|
||||||
if (email.trim()) {
|
if (email.trim()) {
|
||||||
url.searchParams.append("email", email.trim());
|
url.searchParams.append("email", email.trim());
|
||||||
}
|
}
|
||||||
|
if (call.trim()) {
|
||||||
|
url.searchParams.append("call", call.trim());
|
||||||
|
}
|
||||||
if (delay.trim()) {
|
if (delay.trim()) {
|
||||||
url.searchParams.append("delay", delay.trim());
|
url.searchParams.append("delay", delay.trim());
|
||||||
}
|
}
|
||||||
|
@ -406,6 +411,27 @@ const PublishDialog = (props) => {
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
}
|
||||||
|
{showCall &&
|
||||||
|
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
|
||||||
|
setCall("");
|
||||||
|
setShowCall(false);
|
||||||
|
}}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label={t("publish_dialog_call_label")}
|
||||||
|
placeholder={t("publish_dialog_call_placeholder")}
|
||||||
|
value={call}
|
||||||
|
onChange={ev => setCall(ev.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
type="tel"
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("publish_dialog_call_label")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ClosableRow>
|
||||||
|
}
|
||||||
{showAttachUrl &&
|
{showAttachUrl &&
|
||||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
||||||
setAttachUrl("");
|
setAttachUrl("");
|
||||||
|
@ -510,6 +536,7 @@ const PublishDialog = (props) => {
|
||||||
<div>
|
<div>
|
||||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
|
|
@ -288,7 +288,7 @@ const LoginPage = (props) => {
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={error}>
|
<DialogFooter status={error}>
|
||||||
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
|
<Button onClick={props.onBack}>{t("common_back")}</Button>
|
||||||
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
|
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -277,14 +277,14 @@ const LimitReachedChip = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProChip = () => {
|
export const ProChip = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
label={"ntfy Pro"}
|
label={"ntfy Pro"}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
|
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -298,11 +298,12 @@ const TierCard = (props) => {
|
||||||
</div>
|
</div>
|
||||||
<List dense>
|
<List dense>
|
||||||
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
|
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
|
||||||
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
|
|
||||||
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
|
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
|
||||||
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
|
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
|
||||||
|
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
|
||||||
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
|
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
|
||||||
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature>
|
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
|
||||||
|
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
|
||||||
</List>
|
</List>
|
||||||
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
|
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
|
||||||
<Typography variant="body2" color="gray">
|
<Typography variant="body2" color="gray">
|
||||||
|
|
Loading…
Reference in New Issue