WIP calls, remove SMS

This commit is contained in:
binwiederhier 2023-05-12 20:01:12 -04:00
parent d4767caf30
commit f99159ee5b
16 changed files with 132 additions and 301 deletions

View file

@ -71,7 +71,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}),
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 and text messages"}),
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"}),
@ -84,7 +84,6 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-sms-daily-limit", Aliases: []string{"visitor_sms_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_SMS_DAILY_LIMIT"}, Value: server.DefaultVisitorSMSDailyLimit, Usage: "max number of SMS messages per visitor per day"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-call-daily-limit", Aliases: []string{"visitor_call_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_CALL_DAILY_LIMIT"}, Value: server.DefaultVisitorCallDailyLimit, Usage: "max number of phone calls per visitor per day"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
@ -172,7 +171,6 @@ func execServe(c *cli.Context) error {
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
visitorSMSDailyLimit := c.Int("visitor-sms-daily-limit")
visitorCallDailyLimit := c.Int("visitor-call-daily-limit")
behindProxy := c.Bool("behind-proxy")
stripeSecretKey := c.String("stripe-secret-key")
@ -336,7 +334,6 @@ func execServe(c *cli.Context) error {
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorSMSDailyLimit = visitorSMSDailyLimit
conf.VisitorCallDailyLimit = visitorCallDailyLimit
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.BehindProxy = behindProxy

View file

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

View file

@ -2695,51 +2695,48 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption>
</figure>
## Text message (SMS)
## Phone calls
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can forward messages as text message (SMS) by specifying a phone number a header. 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.
You can use ntfy to call a phone and **read the message out loud using text-to-speech**, by specifying a phone number a header.
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.
To forward a message as an SMS, pass a phone number in the `X-SMS` header (or its alias: `SMS`), prefixed with a plus sign
and the country code, e.g. `+12223334444`.
Phone numbers have to be previously verified (via the web app). To forward a message as a phone 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 if you only have one verified phone number.
On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
=== "Command line (curl)"
```
curl \
-H "SMS: +12223334444" \
-d "Your garage seems to be on fire 🔥. You should probably check that out, and call 0118 999 881 999 119 725 3." \
-H "Call: +12223334444" \
-d "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." \
ntfy.sh/alerts
```
=== "ntfy CLI"
```
ntfy publish \
--email=phil@example.com \
--tags=warning,skull,backup-host,ssh-login \
--priority=high \
alerts "Unknown login from 5.31.23.83 to backups.example.com"
--call=+12223334444 \
alerts "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help."
```
=== "HTTP"
``` http
POST /alerts HTTP/1.1
Host: ntfy.sh
Email: phil@example.com
Tags: warning,skull,backup-host,ssh-login
Priority: high
Call: +12223334444
Unknown login from 5.31.23.83 to backups.example.com
Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/alerts', {
method: 'POST',
body: "Unknown login from 5.31.23.83 to backups.example.com",
body: "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.",
headers: {
'Email': 'phil@example.com',
'Tags': 'warning,skull,backup-host,ssh-login',
@ -2807,125 +2804,6 @@ Here's what that looks like in Google Mail:
<figcaption>E-mail notification</figcaption>
</figure>
## Phone calls
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
Only one e-mail address is supported.
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
that, your IP address appears in the e-mail body. This is to prevent abuse.
=== "Command line (curl)"
```
curl \
-H "Email: phil@example.com" \
-H "Tags: warning,skull,backup-host,ssh-login" \
-H "Priority: high" \
-d "Unknown login from 5.31.23.83 to backups.example.com" \
ntfy.sh/alerts
curl -H "Email: phil@example.com" -d "You've Got Mail"
curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com"
```
=== "ntfy CLI"
```
ntfy publish \
--email=phil@example.com \
--tags=warning,skull,backup-host,ssh-login \
--priority=high \
alerts "Unknown login from 5.31.23.83 to backups.example.com"
```
=== "HTTP"
``` http
POST /alerts HTTP/1.1
Host: ntfy.sh
Email: phil@example.com
Tags: warning,skull,backup-host,ssh-login
Priority: high
Unknown login from 5.31.23.83 to backups.example.com
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/alerts', {
method: 'POST',
body: "Unknown login from 5.31.23.83 to backups.example.com",
headers: {
'Email': 'phil@example.com',
'Tags': 'warning,skull,backup-host,ssh-login',
'Priority': 'high'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com"))
req.Header.Set("Email", "phil@example.com")
req.Header.Set("Tags", "warning,skull,backup-host,ssh-login")
req.Header.Set("Priority", "high")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/alerts"
Headers = @{
Title = "Low disk space alert"
Priority = "high"
Tags = "warning,skull,backup-host,ssh-login")
Email = "phil@example.com"
}
Body = "Unknown login from 5.31.23.83 to backups.example.com"
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
requests.post("https://ntfy.sh/alerts",
data="Unknown login from 5.31.23.83 to backups.example.com",
headers={
"Email": "phil@example.com",
"Tags": "warning,skull,backup-host,ssh-login",
"Priority": "high"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"Email: phil@example.com\r\n" .
"Tags: warning,skull,backup-host,ssh-login\r\n" .
"Priority: high",
'content' => 'Unknown login from 5.31.23.83 to backups.example.com'
]
]));
```
Here's what that looks like in Google Mail:
<figure markdown>
![e-mail notification](static/img/screenshot-email.png){ width=600 }
<figcaption>E-mail notification</figcaption>
</figure>
## Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.

View file

@ -47,7 +47,6 @@ const (
DefaultVisitorMessageDailyLimit = 0
DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorSMSDailyLimit = 10
DefaultVisitorCallDailyLimit = 10
DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
@ -130,7 +129,6 @@ type Config struct {
VisitorMessageDailyLimit int
VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration
VisitorSMSDailyLimit int
VisitorCallDailyLimit int
VisitorAccountCreationLimitBurst int
VisitorAccountCreationLimitReplenish time.Duration

View file

@ -106,14 +106,16 @@ var (
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: SMS and calling is disabled", "https://ntfy.sh/docs/publish/#sms", nil}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#sms", nil}
errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
@ -126,8 +128,7 @@ var (
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitSMS = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily SMS quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitCalls = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}

View file

@ -534,7 +534,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "",
EnableSMS: s.config.TwilioAccount != "",
EnableCalls: s.config.TwilioAccount != "",
EnableReservations: s.config.EnableReservations,
BillingContact: s.config.BillingContact,
@ -676,7 +675,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m)
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
if e != nil {
return nil, e.With(t)
}
@ -690,8 +689,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
} else if sms != "" && !vrate.SMSAllowed() {
return nil, errHTTPTooManyRequestsLimitSMS.With(t)
} else if call != "" && !vrate.CallAllowed() {
return nil, errHTTPTooManyRequestsLimitCalls.With(t)
}
@ -734,9 +731,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if s.smtpSender != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && sms != "" {
go s.sendSMS(v, r, m, sms)
}
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call)
}
@ -849,7 +843,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, sms, call string, unifiedpush bool, err *errHTTP) {
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
@ -865,7 +859,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if attach != "" {
if !urlRegex.MatchString(attach) {
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
@ -883,25 +877,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if icon != "" {
if !urlRegex.MatchString(icon) {
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
}
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if s.smtpSender == nil && email != "" {
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
}
sms = readParam(r, "x-sms", "sms")
if sms != "" && s.config.TwilioAccount == "" {
return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
} else if sms != "" && !phoneNumberRegex.MatchString(sms) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
if call != "" && s.config.TwilioAccount == "" {
return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
return false, false, "", "", false, errHTTPBadRequestTwilioDisabled
} else if call != "" && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
@ -910,7 +898,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if e != nil {
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
}
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
for i, t := range m.Tags {
@ -919,18 +907,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
@ -938,7 +926,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if actionsStr != "" {
m.Actions, e = parseActions(actionsStr)
if e != nil {
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
@ -952,7 +940,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
cache = false
email = ""
}
return cache, firebase, email, sms, call, 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.

View file

@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
Messages: limits.MessageLimit,
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
Emails: limits.EmailLimit,
SMS: limits.SMSLimit,
Calls: limits.CallLimit,
Reservations: limits.ReservationsLimit,
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
@ -69,8 +68,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
MessagesRemaining: stats.MessagesRemaining,
Emails: stats.Emails,
EmailsRemaining: stats.EmailsRemaining,
SMS: stats.SMS,
SMSRemaining: stats.SMSRemaining,
Calls: stats.Calls,
CallsRemaining: stats.CallsRemaining,
Reservations: stats.Reservations,
@ -542,7 +539,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
// Check user is allowed to add phone numbers
if u == nil || (u.IsUser() && u.Tier == nil) {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 {
} else if u.IsUser() && u.Tier.CallLimit == 0 {
return errHTTPUnauthorized
}
// Actually add the unverified number, and send verification
@ -553,6 +550,9 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
}).
Debug("Adding phone number, and sending verification")
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
if err == user.ErrPhoneNumberExists {
return errHTTPConflictPhoneNumberExists
}
return err
}
if err := s.verifyPhone(v, r, req.Number); err != nil {
@ -570,10 +570,6 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
// Check user is allowed to add phone numbers
if u == nil {
return errHTTPUnauthorized
}
// Get phone numbers, and check if it's in the list
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
@ -581,7 +577,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
}
found := false
for _, phoneNumber := range phoneNumbers {
if phoneNumber.Number == req.Number && phoneNumber.Verified {
if phoneNumber.Number == req.Number && !phoneNumber.Verified {
found = true
break
}

View file

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

View file

@ -15,7 +15,6 @@ import (
)
const (
twilioMessageEndpoint = "Messages.json"
twilioMessageFooterFormat = "This message was sent by %s via %s"
twilioCallEndpoint = "Calls.json"
twilioCallFormat = `
@ -32,15 +31,6 @@ const (
</Response>`
)
func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m))
data := url.Values{}
data.Set("From", s.config.TwilioFromNumber)
data.Set("To", to)
data.Set("Body", body)
s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data)
}
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m)))
data := url.Values{}
@ -85,25 +75,38 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioAccount)
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")
log.Fields(httpContext(req)).Field("http_body", data.Encode()).Info("Twilio call")
ev := logvr(v, r).
Tag(tagTwilio).
Field("twilio_to", phoneNumber)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
return
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
}
ev := logvr(v, r).Tag(tagTwilio)
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {

View file

@ -362,7 +362,6 @@ type apiConfigResponse struct {
EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableSMS bool `json:"enable_sms"`
EnableCalls bool `json:"enable_calls"`
EnableReservations bool `json:"enable_reservations"`
BillingContact string `json:"billing_contact"`

View file

@ -56,7 +56,6 @@ type visitor struct {
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
messagesLimiter *util.FixedLimiter // Rate limiter for messages
emailsLimiter *util.RateLimiter // Rate limiter for emails
smsLimiter *util.FixedLimiter // Rate limiter for SMS
callsLimiter *util.FixedLimiter // Rate limiter for calls
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
@ -81,7 +80,6 @@ type visitorLimits struct {
EmailLimit int64
EmailLimitBurst int
EmailLimitReplenish rate.Limit
SMSLimit int64
CallLimit int64
ReservationsLimit int64
AttachmentTotalSizeLimit int64
@ -95,8 +93,6 @@ type visitorStats struct {
MessagesRemaining int64
Emails int64
EmailsRemaining int64
SMS int64
SMSRemaining int64
Calls int64
CallsRemaining int64
Reservations int64
@ -115,11 +111,10 @@ const (
)
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messages, emails, sms, calls int64
var messages, emails, calls int64
if user != nil {
messages = user.Stats.Messages
emails = user.Stats.Emails
sms = user.Stats.SMS
calls = user.Stats.Calls
}
v := &visitor{
@ -134,13 +129,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
requestLimiter: nil, // Set in resetLimiters
messagesLimiter: nil, // Set in resetLimiters, may be nil
emailsLimiter: nil, // Set in resetLimiters
smsLimiter: nil, // Set in resetLimiters, may be nil
callsLimiter: nil, // Set in resetLimiters, may be nil
bandwidthLimiter: nil, // Set in resetLimiters
accountLimiter: nil, // Set in resetLimiters, may be nil
authLimiter: nil, // Set in resetLimiters, may be nil
}
v.resetLimitersNoLock(messages, emails, sms, calls, false)
v.resetLimitersNoLock(messages, emails, calls, false)
return v
}
@ -168,9 +162,6 @@ func (v *visitor) contextNoLock() log.Context {
fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
}
if v.config.TwilioAccount != "" {
fields["visitor_sms"] = info.Stats.SMS
fields["visitor_sms_limit"] = info.Limits.SMSLimit
fields["visitor_sms_remaining"] = info.Stats.SMSRemaining
fields["visitor_calls"] = info.Stats.Calls
fields["visitor_calls_limit"] = info.Limits.CallLimit
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
@ -238,12 +229,6 @@ func (v *visitor) EmailAllowed() bool {
return v.emailsLimiter.Allow()
}
func (v *visitor) SMSAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
return v.smsLimiter.Allow()
}
func (v *visitor) CallAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
@ -330,7 +315,6 @@ func (v *visitor) Stats() *user.Stats {
return &user.Stats{
Messages: v.messagesLimiter.Value(),
Emails: v.emailsLimiter.Value(),
SMS: v.smsLimiter.Value(),
Calls: v.callsLimiter.Value(),
}
}
@ -340,7 +324,6 @@ func (v *visitor) ResetStats() {
defer v.mu.RUnlock()
v.emailsLimiter.Reset()
v.messagesLimiter.Reset()
v.smsLimiter.Reset()
v.callsLimiter.Reset()
}
@ -372,11 +355,11 @@ func (v *visitor) SetUser(u *user.User) {
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
v.user = u // u may be nil!
if shouldResetLimiters {
var messages, emails, sms, calls int64
var messages, emails, calls int64
if u != nil {
messages, emails, sms, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.SMS, u.Stats.Calls
messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls
}
v.resetLimitersNoLock(messages, emails, sms, calls, true)
v.resetLimitersNoLock(messages, emails, calls, true)
}
}
@ -391,12 +374,11 @@ func (v *visitor) MaybeUserID() string {
return ""
}
func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueueUpdate bool) {
func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {
limits := v.limitsNoLock()
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
v.smsLimiter = util.NewFixedLimiterWithValue(limits.SMSLimit, sms)
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
if v.user == nil {
@ -410,7 +392,6 @@ func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueu
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
Messages: messages,
Emails: emails,
SMS: sms,
Calls: calls,
})
}
@ -440,7 +421,6 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
EmailLimit: tier.EmailLimit,
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
SMSLimit: tier.SMSLimit,
CallLimit: tier.CallLimit,
ReservationsLimit: tier.ReservationLimit,
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
@ -464,7 +444,6 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
SMSLimit: int64(conf.VisitorSMSDailyLimit),
CallLimit: int64(conf.VisitorCallDailyLimit),
ReservationsLimit: visitorDefaultReservationsLimit,
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
@ -511,7 +490,6 @@ func (v *visitor) Info() (*visitorInfo, error) {
func (v *visitor) infoLightNoLock() *visitorInfo {
messages := v.messagesLimiter.Value()
emails := v.emailsLimiter.Value()
sms := v.smsLimiter.Value()
calls := v.callsLimiter.Value()
limits := v.limitsNoLock()
stats := &visitorStats{
@ -519,8 +497,6 @@ func (v *visitor) infoLightNoLock() *visitorInfo {
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
Emails: emails,
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
SMS: sms,
SMSRemaining: zeroIfNegative(limits.SMSLimit - sms),
Calls: calls,
CallsRemaining: zeroIfNegative(limits.CallLimit - calls),
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/mattn/go-sqlite3"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt"
@ -55,7 +56,6 @@ const (
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
sms_limit INT NOT NULL,
calls_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
@ -78,7 +78,6 @@ const (
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_sms INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
@ -135,26 +134,26 @@ const (
`
selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, 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.sms_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
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ?
`
selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, 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.sms_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
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, 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.sms_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
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
`
selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, 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.sms_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
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ?
@ -185,8 +184,8 @@ const (
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_sms = ?, stats_calls = ? WHERE id = ?`
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_sms = 0, stats_calls = 0`
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
deleteUserQuery = `DELETE FROM user WHERE user = ?`
@ -274,25 +273,25 @@ const (
updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?`
insertTierQuery = `
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
updateTierQuery = `
UPDATE tier
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, sms_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 = ?
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
WHERE code = ?
`
selectTiersQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_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
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
`
selectTierByCodeQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_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
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE code = ?
`
selectTierByPriceIDQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_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
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
`
@ -410,9 +409,7 @@ const (
// 3 -> 4
migrate3To4UpdateQueries = `
ALTER TABLE tier ADD COLUMN sms_limit INT NOT NULL DEFAULT (0);
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_sms 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,
@ -689,6 +686,9 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) {
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
@ -783,11 +783,10 @@ func (a *Manager) writeUserStatsQueue() error {
"user_id": userID,
"messages_count": update.Messages,
"emails_count": update.Emails,
"sms_count": update.SMS,
"calls_count": update.Calls,
}).
Trace("Updating stats for user %s", userID)
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.SMS, update.Calls, userID); err != nil {
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil {
return err
}
}
@ -869,6 +868,9 @@ func (a *Manager) AddUser(username, password string, role Role) error {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return ErrUserExists
}
return err
}
return nil
@ -996,12 +998,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var id, username, hash, role, prefs, syncTopic string
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, sms, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() {
return nil, ErrUserNotFound
}
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@ -1016,7 +1018,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
Stats: &Stats{
Messages: messages,
Emails: emails,
SMS: sms,
Calls: calls,
},
Billing: &Billing{
@ -1041,7 +1042,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64,
SMSLimit: smsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
@ -1348,7 +1348,7 @@ func (a *Manager) AddTier(tier *Tier) error {
if tier.ID == "" {
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
}
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
return err
}
return nil
@ -1356,7 +1356,7 @@ func (a *Manager) AddTier(tier *Tier) error {
// UpdateTier updates a tier's properties in the database
func (a *Manager) UpdateTier(tier *Tier) error {
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
return err
}
return nil
@ -1425,11 +1425,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
var id, code, name string
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
if !rows.Next() {
return nil, ErrTierNotFound
}
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@ -1442,7 +1442,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64,
SMSLimit: smsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,

View file

@ -91,7 +91,6 @@ type Tier struct {
MessageLimit int64 // Daily message limit
MessageExpiryDuration time.Duration // Cache duration for messages
EmailLimit int64 // Daily email limit
SMSLimit int64 // Daily SMS limit
CallLimit int64 // Daily phone call limit
ReservationLimit int64 // Number of topic reservations allowed by user
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
@ -138,7 +137,6 @@ type NotificationPrefs struct {
type Stats struct {
Messages int64
Emails int64
SMS int64
Calls int64
}
@ -285,8 +283,10 @@ var (
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists")
)

View file

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

View file

@ -127,9 +127,6 @@
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
"publish_dialog_email_reset": "Remove email forward",
"publish_dialog_sms_label": "SMS",
"publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444",
"publish_dialog_sms_reset": "Remove SMS message",
"publish_dialog_call_label": "Phone call",
"publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444",
"publish_dialog_call_reset": "Remove phone call",
@ -144,7 +141,6 @@
"publish_dialog_other_features": "Other features:",
"publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Forward to email",
"publish_dialog_chip_sms_label": "Send SMS",
"publish_dialog_chip_call_label": "Phone call",
"publish_dialog_chip_attach_url_label": "Attach file by URL",
"publish_dialog_chip_attach_file_label": "Attach local file",
@ -190,6 +186,8 @@
"account_basics_password_dialog_confirm_password_label": "Confirm password",
"account_basics_password_dialog_button_submit": "Change password",
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
"account_basics_phone_numbers_title": "Phone numbers",
"account_basics_phone_numbers_description": "For phone call notifications",
"account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited",
@ -211,8 +209,6 @@
"account_basics_tier_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent",
"account_usage_sms_title": "SMS sent",
"account_usage_sms_none": "No SMS can be sent with this account",
"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",
@ -244,9 +240,6 @@
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
"account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS",
"account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS",
"account_upgrade_dialog_tier_features_no_sms": "No daily SMS",
"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 daily phone calls",

View file

@ -3,7 +3,7 @@ import {useContext, useState} from 'react';
import {
Alert,
CardActions,
CardContent,
CardContent, Chip,
FormControl,
LinearProgress,
Link,
@ -52,6 +52,7 @@ import MenuItem from "@mui/material/MenuItem";
import DialogContentText from "@mui/material/DialogContentText";
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
import {ProChip} from "./SubscriptionPopup";
import AddIcon from "@mui/icons-material/Add";
const Account = () => {
if (!session.exists()) {
@ -80,6 +81,7 @@ const Basics = () => {
<PrefGroup>
<Username/>
<ChangePassword/>
<PhoneNumbers/>
<AccountType/>
</PrefGroup>
</Card>
@ -320,6 +322,40 @@ const AccountType = () => {
)
};
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const labelId = "prefPhoneNumbers";
const handleAdd = () => {
};
const handleClick = () => {
};
const handleDelete = () => {
};
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(p =>
<Chip
label={p.number}
variant="outlined"
onClick={() => navigator.clipboard.writeText(p.number)}
onDelete={() => handleDelete(p.number)}
/>
)}
<IconButton onClick={() => handleAdd()}><AddIcon/></IconButton>
</div>
</Pref>
)
};
const Stats = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
@ -380,23 +416,6 @@ const Stats = () => {
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
/>
</Pref>
{(account.role === Role.ADMIN || account.limits.sms > 0) &&
<Pref title={
<>
{t("account_usage_sms_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.sms.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.sms, account.limits.sms) : 100}
/>
</Pref>
}
{(account.role === Role.ADMIN || account.limits.calls > 0) &&
<Pref title={
<>
@ -410,7 +429,7 @@ const Stats = () => {
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
/>
</Pref>
}
@ -439,11 +458,6 @@ const Stats = () => {
<em>{t("account_usage_reservations_none")}</em>
</Pref>
}
{config.enable_sms && account.role === Role.USER && account.limits.sms === 0 &&
<Pref title={<>{t("account_usage_sms_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_sms_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>