From 1c0162c43457e7e7a11c215b014aa537af67bbc4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 5 May 2023 16:22:54 -0400 Subject: [PATCH 01/20] WIP: Twilio --- cmd/serve.go | 11 +++++ server/config.go | 6 +++ server/errors.go | 2 + server/log.go | 1 + server/server.go | 45 ++++++++++++------ server/server.yml | 6 +++ server/server_twilio.go | 101 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 server/server_twilio.go diff --git a/cmd/serve.go b/cmd/serve.go index 912e295a..bef09e1c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,6 +71,9 @@ 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-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.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), @@ -151,6 +154,9 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") + twilioAccount := c.String("twilio-account") + twilioAuthToken := c.String("twilio-auth-token") + twilioFromNumber := c.String("twilio-from-number") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") @@ -209,6 +215,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") { + return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number and base-url must also be set") } // Backwards compatibility @@ -308,6 +316,9 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix + conf.TwilioAccount = twilioAccount + conf.TwilioAuthToken = twilioAuthToken + conf.TwilioFromNumber = twilioFromNumber conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit diff --git a/server/config.go b/server/config.go index 59da448a..8fc01411 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,9 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioAccount string + TwilioAuthToken string + TwilioFromNumber string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -183,6 +186,9 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", + TwilioAccount: "", + TwilioAuthToken: "", + TwilioFromNumber: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, diff --git a/server/errors.go b/server/errors.go index 8e565197..236b4e0c 100644 --- a/server/errors.go +++ b/server/errors.go @@ -106,6 +106,8 @@ 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} 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} diff --git a/server/log.go b/server/log.go index 643f2ccb..c638ed97 100644 --- a/server/log.go +++ b/server/log.go @@ -20,6 +20,7 @@ const ( tagFirebase = "firebase" tagSMTP = "smtp" // Receive email tagEmail = "email" // Send email + tagTwilio = "twilio" tagFileCache = "file_cache" tagMessageCache = "message_cache" tagStripe = "stripe" diff --git a/server/server.go b/server/server.go index c0ebc6eb..7bc096f8 100644 --- a/server/server.go +++ b/server/server.go @@ -98,6 +98,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`) //go:embed site webFs embed.FS @@ -668,7 +669,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -722,6 +723,12 @@ 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 call != "" { + go s.callPhone(v, r, m, call) + } if s.config.UpstreamBaseURL != "" { go s.forwardPollRequest(v, m) } @@ -831,7 +838,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, sms, 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")) @@ -847,7 +854,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 == "" { @@ -865,13 +872,25 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled + } + 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 + } + call = readParam(r, "x-call", "call") + if call != "" && s.config.TwilioAccount == "" { + return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled + } else if call != "" && !phoneNumberRegex.MatchString(call) { + return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -880,7 +899,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 { @@ -889,18 +908,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() } @@ -908,7 +927,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! @@ -922,7 +941,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, unifiedpush, nil + return cache, firebase, email, sms, call, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. diff --git a/server/server.yml b/server/server.yml index 204005ca..9e515ec3 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,6 +144,12 @@ # smtp-server-domain: # smtp-server-addr-prefix: +# If enabled, ntfy can send SMS text messages and do voice calls via Twilio, and the "X-SMS" and "X-Call" headers. +# +# twilio-account: +# twilio-auth-token: +# twilio-from-number: + # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. # diff --git a/server/server_twilio.go b/server/server_twilio.go new file mode 100644 index 00000000..a9a2edb8 --- /dev/null +++ b/server/server_twilio.go @@ -0,0 +1,101 @@ +package server + +import ( + "fmt" + "heckel.io/ntfy/log" + "heckel.io/ntfy/util" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + twilioMessageEndpoint = "Messages.json" + twilioCallEndpoint = "Calls.json" + twilioCallTemplate = ` + + + You have a message from notify on topic %s. Message: + + %s + + End message. + + %s + +` +) + +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(m)) + data := url.Values{} + data.Set("From", s.config.TwilioFromNumber) + data.Set("To", to) + data.Set("Body", body) + s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data) +} + +func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { + body := fmt.Sprintf(twilioCallTemplate, m.Topic, m.Message, s.messageFooter(m)) + data := url.Values{} + data.Set("From", s.config.TwilioFromNumber) + data.Set("To", to) + data.Set("Twiml", body) + s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data) +} + +func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) { + logContext := log.Context{ + "twilio_from": s.config.TwilioFromNumber, + "twilio_to": to, + } + ev := logvrm(v, r, m).Tag(tagTwilio).Fields(logContext) + if ev.IsTrace() { + ev.Field("twilio_body", body).Trace("Sending Twilio request") + } else if ev.IsDebug() { + ev.Debug("Sending Twilio request") + } + response, err := s.performTwilioRequestInternal(endpoint, data) + if err != nil { + ev. + Field("twilio_body", body). + Field("twilio_response", response). + Err(err). + Warn("Error sending Twilio request") + return + } + if ev.IsTrace() { + ev.Field("twilio_response", response).Trace("Received successful Twilio response") + } else if ev.IsDebug() { + ev.Debug("Received successful Twilio response") + } +} + +func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { + requestURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/%s", s.config.TwilioAccount, endpoint) + 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) messageFooter(m *message) string { + topicURL := s.config.BaseURL + "/" + m.Topic + sender := m.Sender.String() + if m.User != "" { + sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) + } + return fmt.Sprintf("This message was sent by %s via %s", sender, util.ShortTopicURL(topicURL)) +} From 3863357207efbedf89b5245f73c073d3866a6dcc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 5 May 2023 20:14:46 -0400 Subject: [PATCH 02/20] WIP --- server/config.go | 2 ++ server/server_twilio.go | 21 ++++++++++++++------ server/server_twilio_test.go | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 server/server_twilio_test.go diff --git a/server/config.go b/server/config.go index 8fc01411..4f1cbef6 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,7 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string @@ -186,6 +187,7 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", + TwilioBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", diff --git a/server/server_twilio.go b/server/server_twilio.go index a9a2edb8..fc21aaca 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -1,6 +1,8 @@ package server import ( + "bytes" + "encoding/xml" "fmt" "heckel.io/ntfy/log" "heckel.io/ntfy/util" @@ -11,9 +13,10 @@ import ( ) const ( - twilioMessageEndpoint = "Messages.json" - twilioCallEndpoint = "Calls.json" - twilioCallTemplate = ` + twilioMessageEndpoint = "Messages.json" + twilioMessageFooterFormat = "This message was sent by %s via %s" + twilioCallEndpoint = "Calls.json" + twilioCallFormat = ` You have a message from notify on topic %s. Message: @@ -37,7 +40,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf(twilioCallTemplate, m.Topic, m.Message, s.messageFooter(m)) + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(m))) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -73,7 +76,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e } func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/%s", s.config.TwilioAccount, endpoint) + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { return "", err @@ -97,5 +100,11 @@ func (s *Server) messageFooter(m *message) string { if m.User != "" { sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) } - return fmt.Sprintf("This message was sent by %s via %s", sender, util.ShortTopicURL(topicURL)) + return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) +} + +func xmlEscapeText(text string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(text)) + return buf.String() } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go new file mode 100644 index 00000000..16c1274c --- /dev/null +++ b/server/server_twilio_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestServer_Twilio_SMS(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "http://" + c.TwilioAccount = "AC123" + c.TwilioAuthToken = "secret-token" + c.TwilioFromNumber = "+123456789" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "SMS": "+11122233344", + }) + require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) + require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) + require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) + require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) +} From 113b7c8a086e389bf0e12ebceb143fc40158188c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 6 May 2023 14:23:48 -0400 Subject: [PATCH 03/20] Metrics, tests --- server/server.go | 2 +- server/server_metrics.go | 20 ++++++ server/server_twilio.go | 9 ++- server/server_twilio_test.go | 122 ++++++++++++++++++++++++++++------- 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/server/server.go b/server/server.go index 7bc096f8..1a9309ce 100644 --- a/server/server.go +++ b/server/server.go @@ -98,7 +98,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) - phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site webFs embed.FS diff --git a/server/server_metrics.go b/server/server_metrics.go index d3f17929..d2e6f1c0 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,6 +15,10 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter + metricSMSSentSuccess prometheus.Counter + metricSMSSentFailure prometheus.Counter + metricCallsMadeSuccess prometheus.Counter + metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter metricMatrixPublishedSuccess prometheus.Counter metricMatrixPublishedFailure prometheus.Counter @@ -57,6 +61,18 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_emails_received_failure", }) + metricSMSSentSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_success", + }) + metricSMSSentFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_failure", + }) + metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_success", + }) + metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_failure", + }) metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_unifiedpush_published_success", }) @@ -95,6 +111,10 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, + metricSMSSentSuccess, + metricSMSSentFailure, + metricCallsMadeSuccess, + metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, metricMatrixPublishedSuccess, metricMatrixPublishedFailure, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc21aaca..fc5fb65c 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/xml" "fmt" + "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/util" "io" @@ -36,7 +37,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Body", body) - s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { @@ -45,10 +46,10 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) } -func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) { +func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { logContext := log.Context{ "twilio_from": s.config.TwilioFromNumber, "twilio_to": to, @@ -66,6 +67,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e Field("twilio_response", response). Err(err). Warn("Error sending Twilio request") + minc(mfailure) return } if ev.IsTrace() { @@ -73,6 +75,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e } else if ev.IsDebug() { ev.Debug("Received successful Twilio response") } + minc(msuccess) } func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 16c1274c..d99f9b61 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,37 +2,113 @@ package server import ( "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" ) func TestServer_Twilio_SMS(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+9.9.9.9+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + c := newTestConfig(t) - c.TwilioBaseURL = "http://" - c.TwilioAccount = "AC123" - c.TwilioAuthToken = "secret-token" - c.TwilioFromNumber = "+123456789" + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "SMS": "+11122233344", }) - require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) - require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) - require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) - require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) - require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ethis+message+has%26%23xA%3Ba+new+line+and+%26lt%3Bbrackets%26gt%3B%21%26%23xA%3Band+%26%2334%3Bquotes+and+other+%26%2339%3Bquotes%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+9.9.9.9+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfig(t) + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + body := `this message has +a new line and ! +and "quotes and other 'quotes` + response := request(t, s, "POST", "/mytopic", body, map[string]string{ + "x-call": "+11122233344", + }) + require.Equal(t, "this message has\na new line and !\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "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, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "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-sms": "+invalid", + }) + require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-sms": "+1234", + }) + require.Equal(t, 40030, 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, 40030, toHTTPError(t, response.Body.String()).Code) } From f9e2d6ddcbe829ca1297ebc7bf9c455836281508 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 7 May 2023 11:59:15 -0400 Subject: [PATCH 04/20] Add limiters and database changes --- cmd/serve.go | 6 ++++ cmd/tier.go | 16 +++++++++ server/config.go | 4 +++ server/errors.go | 2 ++ server/server.go | 6 +++- server/server.yml | 10 ++++-- server/server_account.go | 6 ++++ server/types.go | 6 ++++ server/visitor.go | 70 ++++++++++++++++++++++++++++++++----- user/manager.go | 75 +++++++++++++++++++++++++++++----------- user/types.go | 4 +++ 11 files changed, 173 insertions(+), 32 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index bef09e1c..6c729753 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -83,6 +83,8 @@ 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"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), @@ -168,6 +170,8 @@ 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") stripeWebhookKey := c.String("stripe-webhook-key") @@ -329,6 +333,8 @@ 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 conf.StripeSecretKey = stripeSecretKey diff --git a/cmd/tier.go b/cmd/tier.go index c0b83d71..6b95bdd2 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,6 +18,8 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 + defaultSMSLimit = 10 + defaultCallLimit = 10 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" @@ -48,6 +50,8 @@ 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"}, &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, @@ -91,6 +95,8 @@ 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"}, &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, @@ -215,6 +221,8 @@ 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, AttachmentTotalSizeLimit: attachmentTotalSizeLimit, @@ -267,6 +275,12 @@ 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") + } if c.IsSet("reservation-limit") { tier.ReservationLimit = c.Int64("reservation-limit") } @@ -357,6 +371,8 @@ 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)) fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) diff --git a/server/config.go b/server/config.go index 4f1cbef6..b6d57d90 100644 --- a/server/config.go +++ b/server/config.go @@ -47,6 +47,8 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorSMSDailyLimit = 10 + DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -126,6 +128,8 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorSMSDailyLimit int + VisitorCallDailyLimit int VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int diff --git a/server/errors.go b/server/errors.go index 236b4e0c..d02fb071 100644 --- a/server/errors.go +++ b/server/errors.go @@ -126,6 +126,8 @@ 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} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} diff --git a/server/server.go b/server/server.go index 1a9309ce..8c2f83ce 100644 --- a/server/server.go +++ b/server/server.go @@ -683,6 +683,10 @@ 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) } if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) @@ -726,7 +730,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.config.TwilioAccount != "" && sms != "" { go s.sendSMS(v, r, m, sms) } - if call != "" { + if s.config.TwilioAccount != "" && call != "" { go s.callPhone(v, r, m, call) } if s.config.UpstreamBaseURL != "" { diff --git a/server/server.yml b/server/server.yml index 9e515ec3..fb4d1d99 100644 --- a/server/server.yml +++ b/server/server.yml @@ -224,11 +224,17 @@ # visitor-request-limit-exempt-hosts: "" # Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset -# every day at midnight UTC. If the limit is not set (or set to zero), the request -# limit (see above) governs the upper limit. +# every day at midnight UTC. If the limit is not set (or set to zero), the request limit (see above) +# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. # # visitor-message-daily-limit: 0 +# Rate limiting: Daily limit of SMS and calls per visitor and day. The limit is reset every day +# at midnight UTC. SMS and calls are only supported if the twilio-settings are properly configured. +# +# visitor-sms-daily-limit: 10 +# visitor-call-daily-limit: 10 + # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled diff --git a/server/server_account.go b/server/server_account.go index 1b2c0ce4..bdc42903 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -56,6 +56,8 @@ 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, AttachmentFileSize: limits.AttachmentFileSizeLimit, @@ -67,6 +69,10 @@ 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, ReservationsRemaining: stats.ReservationsRemaining, AttachmentTotalSize: stats.AttachmentTotalSize, diff --git a/server/types.go b/server/types.go index 563cafbb..ae6724f5 100644 --- a/server/types.go +++ b/server/types.go @@ -287,6 +287,8 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` + SMS int64 `json:"sms"` + Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentFileSize int64 `json:"attachment_file_size"` @@ -299,6 +301,10 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` + SMS int64 `json:"sms"` + SMSRemaining int64 `json:"sms_remaining"` + Calls int64 `json:"calls"` + CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` ReservationsRemaining int64 `json:"reservations_remaining"` AttachmentTotalSize int64 `json:"attachment_total_size"` diff --git a/server/visitor.go b/server/visitor.go index 63a3ac60..4de51e67 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -56,6 +56,8 @@ 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 accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil @@ -79,6 +81,8 @@ type visitorLimits struct { EmailLimit int64 EmailLimitBurst int EmailLimitReplenish rate.Limit + SMSLimit int64 + CallLimit int64 ReservationsLimit int64 AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -91,6 +95,10 @@ type visitorStats struct { MessagesRemaining int64 Emails int64 EmailsRemaining int64 + SMS int64 + SMSRemaining int64 + Calls int64 + CallsRemaining int64 Reservations int64 ReservationsRemaining int64 AttachmentTotalSize int64 @@ -107,10 +115,12 @@ const ( ) func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { - var messages, emails int64 + var messages, emails, sms, calls int64 if user != nil { messages = user.Stats.Messages emails = user.Stats.Emails + sms = user.Stats.SMS + calls = user.Stats.Calls } v := &visitor{ config: conf, @@ -124,11 +134,13 @@ 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, false) + v.resetLimitersNoLock(messages, emails, sms, calls, false) return v } @@ -147,12 +159,22 @@ func (v *visitor) contextNoLock() log.Context { "visitor_messages": info.Stats.Messages, "visitor_messages_limit": info.Limits.MessageLimit, "visitor_messages_remaining": info.Stats.MessagesRemaining, - "visitor_emails": info.Stats.Emails, - "visitor_emails_limit": info.Limits.EmailLimit, - "visitor_emails_remaining": info.Stats.EmailsRemaining, "visitor_request_limiter_limit": v.requestLimiter.Limit(), "visitor_request_limiter_tokens": v.requestLimiter.Tokens(), } + if v.config.SMTPSenderFrom != "" { + fields["visitor_emails"] = info.Stats.Emails + fields["visitor_emails_limit"] = info.Limits.EmailLimit + fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining + } + if v.config.TwilioAccount != "" { + fields["visitor_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 + } if v.authLimiter != nil { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() @@ -216,6 +238,18 @@ 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() + return v.callsLimiter.Allow() +} + func (v *visitor) SubscriptionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() @@ -296,6 +330,8 @@ 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(), } } @@ -304,6 +340,8 @@ func (v *visitor) ResetStats() { defer v.mu.RUnlock() v.emailsLimiter.Reset() v.messagesLimiter.Reset() + v.smsLimiter.Reset() + v.callsLimiter.Reset() } // User returns the visitor user, or nil if there is none @@ -334,11 +372,11 @@ func (v *visitor) SetUser(u *user.User) { shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver v.user = u // u may be nil! if shouldResetLimiters { - var messages, emails int64 + var messages, emails, sms, calls int64 if u != nil { - messages, emails = u.Stats.Messages, u.Stats.Emails + messages, emails, sms, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.SMS, u.Stats.Calls } - v.resetLimitersNoLock(messages, emails, true) + v.resetLimitersNoLock(messages, emails, sms, calls, true) } } @@ -353,11 +391,13 @@ func (v *visitor) MaybeUserID() string { return "" } -func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) { +func (v *visitor) resetLimitersNoLock(messages, emails, sms, 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 { v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) @@ -370,6 +410,8 @@ func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ Messages: messages, Emails: emails, + SMS: sms, + Calls: calls, }) } log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters @@ -398,6 +440,8 @@ 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, AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, @@ -420,6 +464,8 @@ 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, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, @@ -465,12 +511,18 @@ 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{ Messages: messages, 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), } return &visitorInfo{ Limits: limits, diff --git a/user/manager.go b/user/manager.go index b2898ae8..3effd5cd 100644 --- a/user/manager.go +++ b/user/manager.go @@ -55,6 +55,8 @@ 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, attachment_total_size_limit INT NOT NULL, @@ -76,6 +78,8 @@ 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, stripe_subscription_status TEXT, @@ -123,26 +127,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_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.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_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.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_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.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_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.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -173,8 +177,8 @@ const ( updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_sms = ?, stats_calls = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_sms = 0, stats_calls = 0` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` deleteUserQuery = `DELETE FROM user WHERE user = ?` @@ -258,25 +262,25 @@ const ( ` insertTierQuery = ` - INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` updateTierQuery = ` UPDATE tier - SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, 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 = ? WHERE code = ? ` selectTiersQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, 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 FROM tier ` selectTierByCodeQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, 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 FROM tier WHERE code = ? ` selectTierByPriceIDQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, 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 FROM tier WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) ` @@ -293,7 +297,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 3 + currentSchemaVersion = 4 insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -391,12 +395,21 @@ const ( CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); ` + + // 3 -> 4 + migrate3To4UpdateQueries = ` + ALTER TABLE tier ADD COLUMN 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); + ` ) var ( migrations = map[int]func(db *sql.DB) error{ 1: migrateFrom1, 2: migrateFrom2, + 3: migrateFrom3, } ) @@ -700,9 +713,11 @@ 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, userID); err != nil { + if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.SMS, update.Calls, userID); err != nil { return err } } @@ -911,12 +926,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails int64 + var messages, emails, sms, calls int64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -931,6 +946,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { Stats: &Stats{ Messages: messages, Emails: emails, + SMS: sms, + Calls: calls, }, Billing: &Billing{ StripeCustomerID: stripeCustomerID.String, // May be empty @@ -1259,7 +1276,7 @@ func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) } - if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, 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 @@ -1267,7 +1284,7 @@ func (a *Manager) AddTier(tier *Tier) error { // UpdateTier updates a tier's properties in the database func (a *Manager) UpdateTier(tier *Tier) error { - if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.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 { return err } return nil @@ -1336,11 +1353,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 if !rows.Next() { return nil, ErrTierNotFound } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1353,6 +1370,8 @@ 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, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, @@ -1495,6 +1514,22 @@ func migrateFrom2(db *sql.DB) error { return tx.Commit() } +func migrateFrom3(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 3 to 4") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { + return err + } + return tx.Commit() +} + func nullString(s string) sql.NullString { if s == "" { return sql.NullString{} diff --git a/user/types.go b/user/types.go index 2486f110..6340229b 100644 --- a/user/types.go +++ b/user/types.go @@ -86,6 +86,8 @@ 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) AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) @@ -131,6 +133,8 @@ type NotificationPrefs struct { type Stats struct { Messages int64 Emails int64 + SMS int64 + Calls int64 } // Billing is a struct holding a user's billing information From eb0805a4706723aa0011918774de62e4422a040f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 7 May 2023 22:28:07 -0400 Subject: [PATCH 05/20] Update web app with SMS and calls stuff --- docs/releases.md | 4 ++ server/server.go | 2 + server/server_payments.go | 4 ++ server/server_twilio.go | 11 +-- server/server_twilio_test.go | 96 +++++++++++++++++++++++++ server/types.go | 2 + user/manager.go | 14 ++-- web/public/config.js | 4 +- web/public/static/langs/en.json | 18 +++++ web/src/app/utils.js | 6 +- web/src/components/Account.js | 87 ++++++++++++++++------ web/src/components/PublishDialog.js | 54 ++++++++++++++ web/src/components/SubscriptionPopup.js | 4 +- web/src/components/UpgradeDialog.js | 7 +- 14 files changed, 274 insertions(+), 39 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 4b240857..72cc39e5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v2.5.0 (UNRELEASED) +**Features:** + +* Support for SMS and voice calls using Twilio (no ticket) + **Bug fixes + maintenance:** * Removed old ntfy website from ntfy entirely (no ticket) diff --git a/server/server.go b/server/server.go index 8c2f83ce..79aa8085 100644 --- a/server/server.go +++ b/server/server.go @@ -529,6 +529,8 @@ 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, DisallowedTopics: s.config.DisallowedTopics, diff --git a/server/server_payments.go b/server/server_payments.go index cb585966..bd91338e 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,6 +68,8 @@ 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, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, @@ -96,6 +98,8 @@ 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, AttachmentFileSize: tier.AttachmentFileSizeLimit, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc5fb65c..1bd11113 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" + "heckel.io/ntfy/user" "heckel.io/ntfy/util" "io" "net/http" @@ -32,7 +33,7 @@ const ( ) 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(m)) + 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) @@ -41,7 +42,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { } 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(m))) + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -97,11 +98,11 @@ func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) return string(response), nil } -func (s *Server) messageFooter(m *message) string { +func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil! topicURL := s.config.BaseURL + "/" + m.Topic sender := m.Sender.String() - if m.User != "" { - sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) + if u != nil { + sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender) } return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index d99f9b61..913a520d 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,6 +2,8 @@ package server import ( "github.com/stretchr/testify/require" + "heckel.io/ntfy/user" + "heckel.io/ntfy/util" "io" "net/http" "net/http/httptest" @@ -27,6 +29,7 @@ func TestServer_Twilio_SMS(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorSMSDailyLimit = 1 s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ @@ -38,6 +41,56 @@ func TestServer_Twilio_SMS(t *testing.T) { }) } +func TestServer_Twilio_SMS_With_User(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/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = 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, + SMSLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do request with user + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) + + // Second one should fail due to rate limits + response = request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_Twilio_Call(t *testing.T) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -55,6 +108,7 @@ func TestServer_Twilio_Call(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorCallDailyLimit = 1 s := newTestServer(t, c) body := `this message has @@ -69,6 +123,48 @@ and "quotes and other 'quotes` }) } +func TestServer_Twilio_Call_With_User(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%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioBaseURL = 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")) + + // 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_InvalidNumber(t *testing.T) { c := newTestConfig(t) c.TwilioBaseURL = "https://127.0.0.1" diff --git a/server/types.go b/server/types.go index ae6724f5..98ab4e23 100644 --- a/server/types.go +++ b/server/types.go @@ -351,6 +351,8 @@ 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"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/user/manager.go b/user/manager.go index 3effd5cd..017996cf 100644 --- a/user/manager.go +++ b/user/manager.go @@ -127,26 +127,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.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_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 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.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_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 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.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_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 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.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_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 FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -927,11 +927,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { 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, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, 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, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + 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 { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -971,6 +971,8 @@ 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, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, diff --git a/web/public/config.js b/web/public/config.js index 30da6913..f5a5759c 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,12 +6,14 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server + base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, 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"] }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 8760eb31..600994bb 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -127,6 +127,12 @@ "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", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -138,6 +144,8 @@ "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", "publish_dialog_chip_delay_label": "Delay delivery", @@ -203,6 +211,10 @@ "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", "account_usage_reservations_none": "No reserved topics for this account", "account_usage_attachment_storage_title": "Attachment storage", @@ -232,6 +244,12 @@ "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", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6eb4ac54..25b4a459 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -206,10 +206,12 @@ export const formatBytes = (bytes, decimals = 2) => { } export const formatNumber = (n) => { - if (n % 1000 === 0) { + if (n === 0) { + return n; + } else if (n % 1000 === 0) { return `${n/1000}k`; } - return n; + return n.toLocaleString(); } export const formatPrice = (n) => { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index e5b60077..dc80babf 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -51,6 +51,7 @@ import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; +import {ProChip} from "./SubscriptionPopup"; const Account = () => { if (!session.exists()) { @@ -337,23 +338,18 @@ const Stats = () => { {t("account_usage_title")} - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && - <> -
- {account.stats.reservations} - {account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - /> - - } - {account.role === Role.USER && account.limits.reservations === 0 && - {t("account_usage_reservations_none")} - } -
+ {(account.role === Role.ADMIN || account.limits.reservations > 0) && + +
+ {account.stats.reservations.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + /> +
+ } {t("account_usage_messages_title")} @@ -361,8 +357,8 @@ const Stats = () => { }>
- {account.stats.messages} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} + {account.stats.messages.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}
{ }>
- {account.stats.emails} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")} + {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}
+ {(account.role === Role.ADMIN || account.limits.sms > 0) && + + {t("account_usage_sms_title")} + + + }> +
+ {account.stats.sms.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.sms, account.limits.sms) : 100} + /> +
+ } + {(account.role === Role.ADMIN || account.limits.calls > 0) && + + {t("account_usage_calls_title")} + + + }> +
+ {account.stats.calls.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + /> +
+ } { value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> + {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && + {t("account_usage_reservations_title")}{config.enable_payments && }}> + {t("account_usage_reservations_none")} + + } + {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 && + {t("account_usage_sms_title")}{config.enable_payments && }}> + {t("account_usage_sms_none")} + + } + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && + {t("account_usage_calls_title")}{config.enable_payments && }}> + {t("account_usage_calls_none")} + + }
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index bdf6fb62..c410f19d 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,6 +45,8 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); + const [sms, setSms] = useState(""); + const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -52,6 +54,8 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); + const [showSms, setShowSms] = useState(false); + const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); const showAttachFile = !!attachFile && !showAttachUrl; @@ -124,6 +128,12 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } + if (sms.trim()) { + url.searchParams.append("sms", sms.trim()); + } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } if (delay.trim()) { url.searchParams.append("delay", delay.trim()); } @@ -406,6 +416,48 @@ const PublishDialog = (props) => { /> } + {showSms && + { + setSms(""); + setShowSms(false); + }}> + setSms(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_sms_label") + }} + /> + + } + {showCall && + { + setCall(""); + setShowCall(false); + }}> + setCall(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_call_label") + }} + /> + + } {showAttachUrl && { setAttachUrl(""); @@ -510,6 +562,8 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 7655605d..024b6f23 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -277,14 +277,14 @@ const LimitReachedChip = () => { ); }; -const ProChip = () => { +export const ProChip = () => { const { t } = useTranslation(); return ( ); }; diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c62560a3..c4d665e0 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -298,11 +298,14 @@ const TierCard = (props) => {
{tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} + {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} + {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH && From 559f09e7be573c3b2831e61a30229063b7742bce Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 9 May 2023 09:33:01 -0400 Subject: [PATCH 06/20] WIP Docs --- docs/publish.md | 231 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/docs/publish.md b/docs/publish.md index b046bd2a..1f52cf3a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2695,6 +2695,237 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
+## Text message (SMS) +_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. + +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`. + +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." \ + 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" + ``` + +=== "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: + +
+ ![e-mail notification](static/img/screenshot-email.png){ width=600 } +
E-mail notification
+
+ + +## 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: + +
+ ![e-mail notification](static/img/screenshot-email.png){ width=600 } +
E-mail notification
+
+ + ## 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. From d4767caf304217f96d46798f7bb21d9e67bf744a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 11 May 2023 13:50:10 -0400 Subject: [PATCH 07/20] Verify --- cmd/serve.go | 3 ++ server/config.go | 8 +++- server/server.go | 5 +++ server/server.yml | 1 + server/server_account.go | 87 ++++++++++++++++++++++++++++++++++++ server/server_twilio.go | 74 +++++++++++++++++++++++++++--- server/server_twilio_test.go | 12 ++--- server/types.go | 39 +++++++++++----- user/manager.go | 70 +++++++++++++++++++++++++++++ user/types.go | 6 +++ 10 files changed, 279 insertions(+), 26 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 6c729753..42b72332 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -74,6 +74,7 @@ var flagsServe = append( 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-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"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), @@ -159,6 +160,7 @@ func execServe(c *cli.Context) error { twilioAccount := c.String("twilio-account") twilioAuthToken := c.String("twilio-auth-token") twilioFromNumber := c.String("twilio-from-number") + twilioVerifyService := c.String("twilio-verify-service") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") @@ -323,6 +325,7 @@ func execServe(c *cli.Context) error { conf.TwilioAccount = twilioAccount conf.TwilioAuthToken = twilioAuthToken conf.TwilioFromNumber = twilioFromNumber + conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit diff --git a/server/config.go b/server/config.go index b6d57d90..352d91fc 100644 --- a/server/config.go +++ b/server/config.go @@ -107,10 +107,12 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioBaseURL string + TwilioMessagingBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string + TwilioVerifyBaseURL string + TwilioVerifyService string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -191,10 +193,12 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioBaseURL: "https://api.twilio.com", // Override for tests + TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", + TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests + TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, diff --git a/server/server.go b/server/server.go index 79aa8085..056e0a6d 100644 --- a/server/server.go +++ b/server/server.go @@ -88,6 +88,7 @@ var ( apiAccountSettingsPath = "/v1/account/settings" apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" + apiAccountPhonePath = "/v1/account/phone" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -450,6 +451,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { diff --git a/server/server.yml b/server/server.yml index fb4d1d99..f11ad362 100644 --- a/server/server.yml +++ b/server/server.yml @@ -149,6 +149,7 @@ # twilio-account: # twilio-auth-token: # twilio-from-number: +# twilio-verify-service: # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. diff --git a/server/server_account.go b/server/server_account.go index bdc42903..cb3a52ee 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -144,6 +144,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis }) } } + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + if len(phoneNumbers) > 0 { + response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0) + for _, p := range phoneNumbers { + response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{ + Number: p.Number, + Verified: p.Verified, + }) + } + } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -517,6 +530,80 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi return nil } +func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + // 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 { + return errHTTPUnauthorized + } + // Actually add the unverified number, and send verification + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "number": req.Number, + }). + Debug("Adding phone number, and sending verification") + if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { + return err + } + if err := s.verifyPhone(v, r, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + 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 { + return err + } + found := false + for _, phoneNumber := range phoneNumbers { + if phoneNumber.Number == req.Number && phoneNumber.Verified { + found = true + break + } + } + if !found { + return errHTTPBadRequestPhoneNumberInvalid + } + if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { + return err + } + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "number": req.Number, + }). + Debug("Marking phone number as verified") + if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_twilio.go b/server/server_twilio.go index 1bd11113..2f587735 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -38,7 +38,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Body", body) - s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) + s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { @@ -47,10 +47,72 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) + s.twilioMessagingRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) } -func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { +func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) error { + logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") + data := url.Values{} + data.Set("To", phoneNumber) + data.Set("Channel", "sms") + 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) + ev := logvr(v, r).Tag(tagTwilio) + if err != nil { + ev.Err(err).Warn("Error sending Twilio phone verification request") + 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 (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error { + 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.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 + } else if resp.StatusCode != http.StatusOK { + return + } + 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() { + ev.Debug("Received successful Twilio phone verification response") + } + return nil +} + +func (s *Server) twilioMessagingRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { logContext := log.Context{ "twilio_from": s.config.TwilioFromNumber, "twilio_to": to, @@ -61,7 +123,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m } else if ev.IsDebug() { ev.Debug("Sending Twilio request") } - response, err := s.performTwilioRequestInternal(endpoint, data) + response, err := s.performTwilioMessagingRequestInternal(endpoint, data) if err != nil { ev. Field("twilio_body", body). @@ -79,8 +141,8 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m minc(msuccess) } -func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint) +func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) { + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioMessagingBaseURL, s.config.TwilioAccount, endpoint) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { return "", err diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 913a520d..133138f3 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -25,7 +25,7 @@ func TestServer_Twilio_SMS(t *testing.T) { c := newTestConfig(t) c.BaseURL = "https://ntfy.sh" - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -58,7 +58,7 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { c := newTestConfigWithAuthFile(t) c.BaseURL = "https://ntfy.sh" - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -104,7 +104,7 @@ func TestServer_Twilio_Call(t *testing.T) { defer twilioServer.Close() c := newTestConfig(t) - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -139,7 +139,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { defer twilioServer.Close() c := newTestConfigWithAuthFile(t) - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -167,7 +167,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -181,7 +181,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" diff --git a/server/types.go b/server/types.go index 98ab4e23..17622949 100644 --- a/server/types.go +++ b/server/types.go @@ -277,6 +277,16 @@ type apiAccountTokenResponse struct { Expires int64 `json:"expires,omitempty"` // Unix timestamp } +type apiAccountPhoneNumberRequest struct { + Number string `json:"number"` + Code string `json:"code,omitempty"` // Only supplied in "verify" call +} + +type apiAccountPhoneNumberResponse struct { + Number string `json:"number"` + Verified bool `json:"verified"` +} + type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -326,18 +336,19 @@ type apiAccountBilling struct { } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - SyncTopic string `json:"sync_topic,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Reservations []*apiAccountReservation `json:"reservations,omitempty"` - Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` - Tier *apiAccountTier `json:"tier,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` - Billing *apiAccountBilling `json:"billing,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` } type apiAccountReservationRequest struct { @@ -419,3 +430,7 @@ type apiStripeSubscriptionDeletedEvent struct { ID string `json:"id"` Customer string `json:"customer"` } + +type apiTwilioVerifyResponse struct { + Status string `json:"status"` +} diff --git a/user/manager.go b/user/manager.go index 017996cf..824622ba 100644 --- a/user/manager.go +++ b/user/manager.go @@ -113,6 +113,14 @@ const ( PRIMARY KEY (user_id, token), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + verified INT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -261,6 +269,10 @@ const ( ) ` + selectPhoneNumbersQuery = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?` + insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)` + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -402,6 +414,14 @@ const ( 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, + phone_number TEXT NOT NULL, + verified INT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); ` ) @@ -631,6 +651,56 @@ func (a *Manager) RemoveExpiredTokens() error { return nil } +func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { + rows, err := a.db.Query(selectPhoneNumbersQuery, userID) + if err != nil { + return nil, err + } + defer rows.Close() + phoneNumbers := make([]*PhoneNumber, 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) (*PhoneNumber, error) { + var phoneNumber string + var verified bool + if !rows.Next() { + return nil, ErrPhoneNumberNotFound + } + if err := rows.Scan(&phoneNumber, &verified); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + return &PhoneNumber{ + Number: phoneNumber, + Verified: verified, + }, nil +} + +func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { + if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { + return err + } + return nil +} + +func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error { + if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil { + return err + } + return nil +} + // RemoveDeletedUsers deletes all users that have been marked deleted for func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { diff --git a/user/types.go b/user/types.go index 6340229b..8f579e89 100644 --- a/user/types.go +++ b/user/types.go @@ -71,6 +71,11 @@ type TokenUpdate struct { LastOrigin netip.Addr } +type PhoneNumber struct { + Number string + Verified bool +} + // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` @@ -282,5 +287,6 @@ var ( ErrUserNotFound = errors.New("user not found") 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") ) From f99159ee5bed8189ad4ad83fdfab50bb9820319a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 May 2023 20:01:12 -0400 Subject: [PATCH 08/20] WIP calls, remove SMS --- cmd/serve.go | 5 +- cmd/tier.go | 8 -- docs/publish.md | 150 +++----------------------------- server/config.go | 2 - server/errors.go | 9 +- server/server.go | 42 ++++----- server/server_account.go | 14 ++- server/server_payments.go | 2 - server/server_twilio.go | 31 ++++--- server/types.go | 1 - server/visitor.go | 36 ++------ user/manager.go | 55 ++++++------ user/types.go | 4 +- web/public/config.js | 1 - web/public/static/langs/en.json | 11 +-- web/src/components/Account.js | 62 ++++++++----- 16 files changed, 132 insertions(+), 301 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 42b72332..9e020576 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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 diff --git a/cmd/tier.go b/cmd/tier.go index 6b95bdd2..e77927c5 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -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)) diff --git a/docs/publish.md b/docs/publish.md index 1d1109e4..72398a7c 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2695,51 +2695,48 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
-## 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:
E-mail notification
- -## 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: - -
- ![e-mail notification](static/img/screenshot-email.png){ width=600 } -
E-mail notification
-
- - ## 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. diff --git a/server/config.go b/server/config.go index 352d91fc..3fd88e80 100644 --- a/server/config.go +++ b/server/config.go @@ -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 diff --git a/server/errors.go b/server/errors.go index d02fb071..7c4e6899 100644 --- a/server/errors.go +++ b/server/errors.go @@ -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} diff --git a/server/server.go b/server/server.go index 056e0a6d..b474da7f 100644 --- a/server/server.go +++ b/server/server.go @@ -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. diff --git a/server/server_account.go b/server/server_account.go index cb3a52ee..a323bfe0 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -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 } diff --git a/server/server_payments.go b/server/server_payments.go index bd91338e..1e98d059 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -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, diff --git a/server/server_twilio.go b/server/server_twilio.go index 2f587735..a6b91097 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -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 (
` ) -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() { diff --git a/server/types.go b/server/types.go index 17622949..9015a006 100644 --- a/server/types.go +++ b/server/types.go @@ -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"` diff --git a/server/visitor.go b/server/visitor.go index 4de51e67..4895c3f0 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -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), } diff --git a/user/manager.go b/user/manager.go index 824622ba..7c179cf5 100644 --- a/user/manager.go +++ b/user/manager.go @@ -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, diff --git a/user/types.go b/user/types.go index 8f579e89..51a2b3f3 100644 --- a/user/types.go +++ b/user/types.go @@ -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") ) diff --git a/web/public/config.js b/web/public/config.js index f5a5759c..b49e440b 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -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"] diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 600994bb..86330f14 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -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", diff --git a/web/src/components/Account.js b/web/src/components/Account.js index dc80babf..b5294cd5 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -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 = () => { + @@ -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 ( + +
+ {account?.phone_numbers.map(p => + navigator.clipboard.writeText(p.number)} + onDelete={() => handleDelete(p.number)} + /> + )} + handleAdd()}> +
+
+ ) +}; + 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} /> - {(account.role === Role.ADMIN || account.limits.sms > 0) && - - {t("account_usage_sms_title")} - - - }> -
- {account.stats.sms.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.sms, account.limits.sms) : 100} - /> -
- } {(account.role === Role.ADMIN || account.limits.calls > 0) && @@ -410,7 +429,7 @@ const Stats = () => { 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} /> } @@ -439,11 +458,6 @@ const Stats = () => { {t("account_usage_reservations_none")} } - {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 && - {t("account_usage_sms_title")}{config.enable_payments && }}> - {t("account_usage_sms_none")} - - } {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && {t("account_usage_calls_title")}{config.enable_payments && }}> {t("account_usage_calls_none")} From cea434a57cccadbad697322767fc8ef52818d343 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 May 2023 21:47:41 -0400 Subject: [PATCH 09/20] WIP Twilio --- server/server.go | 5 + server/server_account.go | 66 ++++------ server/types.go | 31 ++--- user/manager.go | 39 +++--- user/types.go | 5 - web/public/static/langs/ar.json | 4 +- web/public/static/langs/bg.json | 2 +- web/public/static/langs/cs.json | 4 +- web/public/static/langs/da.json | 4 +- web/public/static/langs/de.json | 4 +- web/public/static/langs/en.json | 14 +- web/public/static/langs/es.json | 4 +- web/public/static/langs/fr.json | 4 +- web/public/static/langs/hu.json | 2 +- web/public/static/langs/id.json | 4 +- web/public/static/langs/it.json | 2 +- web/public/static/langs/ja.json | 4 +- web/public/static/langs/ko.json | 2 +- web/public/static/langs/nb_NO.json | 2 +- web/public/static/langs/nl.json | 4 +- web/public/static/langs/pl.json | 4 +- web/public/static/langs/pt.json | 2 +- web/public/static/langs/pt_BR.json | 2 +- web/public/static/langs/ru.json | 4 +- web/public/static/langs/sv.json | 4 +- web/public/static/langs/tr.json | 4 +- web/public/static/langs/uk.json | 2 +- web/public/static/langs/zh_Hans.json | 4 +- web/public/static/langs/zh_Hant.json | 2 +- web/src/app/AccountApi.js | 39 +++++- web/src/app/utils.js | 1 + web/src/components/Account.js | 176 +++++++++++++++++++++++--- web/src/components/SubscribeDialog.js | 2 +- web/src/components/UpgradeDialog.js | 2 - 34 files changed, 311 insertions(+), 143 deletions(-) diff --git a/server/server.go b/server/server.go index b474da7f..ce87f979 100644 --- a/server/server.go +++ b/server/server.go @@ -455,6 +455,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberDelete))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { @@ -692,6 +694,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e } else if call != "" && !vrate.CallAllowed() { return nil, errHTTPTooManyRequestsLimitCalls.With(t) } + + // FIXME check allowed phone numbers + if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } diff --git a/server/server_account.go b/server/server_account.go index a323bfe0..c5517d66 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -146,13 +146,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis return err } if len(phoneNumbers) > 0 { - response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0) - for _, p := range phoneNumbers { - response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{ - Number: p.Number, - Verified: p.Verified, - }) - } + response.PhoneNumbers = phoneNumbers } } else { response.Username = user.Everyone @@ -542,19 +536,15 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } else if u.IsUser() && u.Tier.CallLimit == 0 { return errHTTPUnauthorized } - // Actually add the unverified number, and send verification - logvr(v, r). - Tag(tagAccount). - Fields(log.Context{ - "number": req.Number, - }). - Debug("Adding phone number, and sending verification") - if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { - if err == user.ErrPhoneNumberExists { - return errHTTPConflictPhoneNumberExists - } + // 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.verifyPhone(v, r, req.Number); err != nil { return err } @@ -570,31 +560,27 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { return errHTTPBadRequestPhoneNumberInvalid } - // Get phone numbers, and check if it's in the list - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } - found := false - for _, phoneNumber := range phoneNumbers { - if phoneNumber.Number == req.Number && !phoneNumber.Verified { - found = true - break - } - } - if !found { - return errHTTPBadRequestPhoneNumberInvalid - } if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { return err } - logvr(v, r). - Tag(tagAccount). - Fields(log.Context{ - "number": req.Number, - }). - Debug("Marking phone number as verified") - if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil { + 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[apiAccountPhoneNumberRequest](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.DeletePhoneNumber(u.ID, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/server/types.go b/server/types.go index 9015a006..d660e717 100644 --- a/server/types.go +++ b/server/types.go @@ -282,11 +282,6 @@ type apiAccountPhoneNumberRequest struct { Code string `json:"code,omitempty"` // Only supplied in "verify" call } -type apiAccountPhoneNumberResponse struct { - Number string `json:"number"` - Verified bool `json:"verified"` -} - type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -336,19 +331,19 @@ type apiAccountBilling struct { } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - SyncTopic string `json:"sync_topic,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Reservations []*apiAccountReservation `json:"reservations,omitempty"` - Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` - PhoneNumbers []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"` - Tier *apiAccountTier `json:"tier,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` - Billing *apiAccountBilling `json:"billing,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []string `json:"phone_numbers,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` } type apiAccountReservationRequest struct { diff --git a/user/manager.go b/user/manager.go index 7c179cf5..7a030951 100644 --- a/user/manager.go +++ b/user/manager.go @@ -115,7 +115,6 @@ const ( CREATE TABLE IF NOT EXISTS user_phone ( user_id TEXT NOT NULL, phone_number TEXT NOT NULL, - verified INT NOT NULL, PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); @@ -268,9 +267,9 @@ const ( ) ` - selectPhoneNumbersQuery = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?` - insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)` - updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?` + selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` + insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` + deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` insertTierQuery = ` INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, 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) @@ -414,7 +413,6 @@ const ( CREATE TABLE IF NOT EXISTS user_phone ( user_id TEXT NOT NULL, phone_number TEXT NOT NULL, - verified INT NOT NULL, PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); @@ -648,13 +646,14 @@ func (a *Manager) RemoveExpiredTokens() error { return nil } -func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { +// 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([]*PhoneNumber, 0) + phoneNumbers := make([]string, 0) for { phoneNumber, err := a.readPhoneNumber(rows) if err == ErrPhoneNumberNotFound { @@ -667,23 +666,20 @@ func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { return phoneNumbers, nil } -func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) { +func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { var phoneNumber string - var verified bool if !rows.Next() { - return nil, ErrPhoneNumberNotFound + return "", ErrPhoneNumberNotFound } - if err := rows.Scan(&phoneNumber, &verified); err != nil { - return nil, err + if err := rows.Scan(&phoneNumber); err != nil { + return "", err } else if err := rows.Err(); err != nil { - return nil, err + return "", err } - return &PhoneNumber{ - Number: phoneNumber, - Verified: verified, - }, nil + 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 { @@ -694,11 +690,10 @@ func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { return nil } -func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error { - if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil { - return err - } - return nil +// DeletePhoneNumber deletes a phone number from the user with the given user ID +func (a *Manager) DeletePhoneNumber(userID string, phoneNumber string) error { + _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) + return err } // RemoveDeletedUsers deletes all users that have been marked deleted for diff --git a/user/types.go b/user/types.go index 51a2b3f3..11895785 100644 --- a/user/types.go +++ b/user/types.go @@ -71,11 +71,6 @@ type TokenUpdate struct { LastOrigin netip.Addr } -type PhoneNumber struct { - Number string - Verified bool -} - // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json index a3919ffd..0c9fcc7d 100644 --- a/web/public/static/langs/ar.json +++ b/web/public/static/langs/ar.json @@ -152,7 +152,7 @@ "publish_dialog_chip_delay_label": "تأخير التسليم", "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", "subscribe_dialog_subscribe_button_cancel": "إلغاء", - "subscribe_dialog_login_button_back": "العودة", + "common_back": "العودة", "prefs_notifications_sound_play": "تشغيل الصوت المحدد", "prefs_notifications_min_priority_title": "أولوية دنيا", "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", @@ -225,7 +225,7 @@ "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", "account_tokens_table_current_session": "جلسة المتصفح الحالية", - "account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة", + "common_copy_to_clipboard": "انسخ إلى الحافظة", "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية", "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول", "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index 8178c469..a040b015 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -104,7 +104,7 @@ "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_login_username_label": "Потребител, напр. phil", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_subscribe_button_subscribe": "Абониране", diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index f8826584..aeff195b 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -91,7 +91,7 @@ "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "subscribe_dialog_login_password_label": "Heslo", - "subscribe_dialog_login_button_back": "Zpět", + "common_back": "Zpět", "subscribe_dialog_login_button_login": "Přihlásit se", "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_anonymous": "anonymně", @@ -305,7 +305,7 @@ "account_tokens_table_expires_header": "Vyprší", "account_tokens_table_never_expires": "Nikdy nevyprší", "account_tokens_table_current_session": "Současná relace prohlížeče", - "account_tokens_table_copy_to_clipboard": "Kopírování do schránky", + "common_copy_to_clipboard": "Kopírování do schránky", "account_tokens_table_label_header": "Popisek", "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", "account_tokens_table_create_token_button": "Vytvořit přístupový token", diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json index d60c56c2..c7477dfc 100644 --- a/web/public/static/langs/da.json +++ b/web/public/static/langs/da.json @@ -91,7 +91,7 @@ "publish_dialog_delay_label": "Forsinkelse", "publish_dialog_button_send": "Send", "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", - "subscribe_dialog_login_button_back": "Tilbage", + "common_back": "Tilbage", "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", "account_basics_title": "Konto", "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", @@ -209,7 +209,7 @@ "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", "account_basics_tier_upgrade_button": "Opgrader til Pro", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder", - "account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder", + "common_copy_to_clipboard": "Kopier til udklipsholder", "prefs_reservations_edit_button": "Rediger emneadgang", "account_upgrade_dialog_title": "Skift kontoniveau", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 88a5c14a..e3f55922 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -94,7 +94,7 @@ "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "prefs_appearance_title": "Darstellung", "subscribe_dialog_login_password_label": "Kennwort", - "subscribe_dialog_login_button_back": "Zurück", + "common_back": "Zurück", "publish_dialog_chip_attach_url_label": "Datei von URL anhängen", "publish_dialog_chip_delay_label": "Auslieferung verzögern", "publish_dialog_chip_topic_label": "Thema ändern", @@ -284,7 +284,7 @@ "account_tokens_table_expires_header": "Verfällt", "account_tokens_table_never_expires": "Verfällt nie", "account_tokens_table_current_session": "Aktuelle Browser-Sitzung", - "account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren", + "common_copy_to_clipboard": "In die Zwischenablage kopieren", "account_tokens_table_copied_to_clipboard": "Access-Token kopiert", "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", "account_tokens_table_create_token_button": "Access-Token erzeugen", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 86330f14..7d8affc0 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -2,6 +2,8 @@ "common_cancel": "Cancel", "common_save": "Save", "common_add": "Add", + "common_back": "Back", + "common_copy_to_clipboard": "Copy to clipboard", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", @@ -169,7 +171,6 @@ "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", "subscribe_dialog_login_username_label": "Username, e.g. phil", "subscribe_dialog_login_password_label": "Password", - "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "subscribe_dialog_error_topic_already_reserved": "Topic already reserved", @@ -187,7 +188,17 @@ "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_phone_numbers_title": "Phone numbers", + "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Adding it will send a verification SMS to your phone.", "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_send_verification_button": "Send verification", + "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_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", @@ -265,7 +276,6 @@ "account_tokens_table_expires_header": "Expires", "account_tokens_table_never_expires": "Never expires", "account_tokens_table_current_session": "Current browser session", - "account_tokens_table_copy_to_clipboard": "Copy to clipboard", "account_tokens_table_copied_to_clipboard": "Access token copied", "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_create_token_button": "Create access token", diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 0fc7c3a4..3166a522 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -81,7 +81,7 @@ "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.", "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil", "subscribe_dialog_login_password_label": "Contraseña", - "subscribe_dialog_login_button_back": "Volver", + "common_back": "Volver", "subscribe_dialog_login_button_login": "Iniciar sesión", "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_anonymous": "anónimo", @@ -257,7 +257,7 @@ "account_tokens_table_expires_header": "Expira", "account_tokens_table_never_expires": "Nunca expira", "account_tokens_table_current_session": "Sesión del navegador actual", - "account_tokens_table_copy_to_clipboard": "Copiar al portapapeles", + "common_copy_to_clipboard": "Copiar al portapapeles", "account_tokens_table_copied_to_clipboard": "Token de acceso copiado", "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual", "account_tokens_table_create_token_button": "Crear token de acceso", diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index a24ece08..ba71eb4a 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -106,7 +106,7 @@ "prefs_notifications_title": "Notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_users_add_button": "Ajouter un utilisateur", - "subscribe_dialog_login_button_back": "Retour", + "common_back": "Retour", "subscribe_dialog_error_user_anonymous": "anonyme", "prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_min_priority_title": "Priorité minimum", @@ -293,7 +293,7 @@ "account_tokens_table_expires_header": "Expire", "account_tokens_table_never_expires": "N'expire jamais", "account_tokens_table_current_session": "Session de navigation actuelle", - "account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier", + "common_copy_to_clipboard": "Copier dans le presse-papier", "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié", "account_tokens_table_create_token_button": "Créer un jeton d'accès", "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index 975d8d97..b52e3a48 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -84,7 +84,7 @@ "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", "subscribe_dialog_login_password_label": "Jelszó", - "subscribe_dialog_login_button_back": "Vissza", + "common_back": "Vissza", "subscribe_dialog_login_button_login": "Belépés", "subscribe_dialog_error_user_anonymous": "névtelen", "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 027653bd..51e6a98a 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -116,7 +116,7 @@ "common_save": "Simpan", "prefs_appearance_title": "Tampilan", "subscribe_dialog_login_password_label": "Kata sandi", - "subscribe_dialog_login_button_back": "Kembali", + "common_back": "Kembali", "prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi", "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi", @@ -278,7 +278,7 @@ "account_tokens_table_expires_header": "Kedaluwarsa", "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa", "account_tokens_table_current_session": "Sesi peramban saat ini", - "account_tokens_table_copy_to_clipboard": "Salin ke papan klip", + "common_copy_to_clipboard": "Salin ke papan klip", "account_tokens_table_copied_to_clipboard": "Token akses disalin", "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", "account_tokens_table_create_token_button": "Buat token akses", diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index 87ea04a4..a62d31fe 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -178,7 +178,7 @@ "prefs_notifications_sound_play": "Riproduci il suono selezionato", "prefs_notifications_min_priority_title": "Priorità minima", "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.", - "subscribe_dialog_login_button_back": "Indietro", + "common_back": "Indietro", "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", "prefs_notifications_title": "Notifiche", "prefs_notifications_delete_after_title": "Elimina le notifiche", diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 1b24ec0d..7eb1c7d4 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -20,7 +20,7 @@ "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_password_label": "パスワード", - "subscribe_dialog_login_button_back": "戻る", + "common_back": "戻る", "subscribe_dialog_login_button_login": "ログイン", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_max_only": "優先度最高のみ", @@ -258,7 +258,7 @@ "account_tokens_table_expires_header": "期限", "account_tokens_table_never_expires": "無期限", "account_tokens_table_current_session": "現在のブラウザセッション", - "account_tokens_table_copy_to_clipboard": "クリップボードにコピー", + "common_copy_to_clipboard": "クリップボードにコピー", "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", "account_tokens_table_create_token_button": "アクセストークンを生成", diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json index 67c31280..2e46c7a1 100644 --- a/web/public/static/langs/ko.json +++ b/web/public/static/langs/ko.json @@ -93,7 +93,7 @@ "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", "subscribe_dialog_login_password_label": "비밀번호", - "subscribe_dialog_login_button_back": "뒤로가기", + "common_back": "뒤로가기", "subscribe_dialog_login_button_login": "로그인", "prefs_notifications_title": "알림", "prefs_notifications_sound_title": "알림 효과음", diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json index 312791da..0dd9571b 100644 --- a/web/public/static/langs/nb_NO.json +++ b/web/public/static/langs/nb_NO.json @@ -113,7 +113,7 @@ "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke", "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned", "priority_min": "min.", - "subscribe_dialog_login_button_back": "Tilbake", + "common_back": "Tilbake", "prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_users_table_base_url_header": "Tjeneste-nettadresse", "common_cancel": "Avbryt", diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index b9ac8e17..ca7a2a13 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -140,7 +140,7 @@ "subscribe_dialog_subscribe_title": "Onderwerp abonneren", "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", "subscribe_dialog_login_password_label": "Wachtwoord", - "subscribe_dialog_login_button_back": "Terug", + "common_back": "Terug", "subscribe_dialog_login_button_login": "Aanmelden", "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", "subscribe_dialog_error_user_anonymous": "anoniem", @@ -331,7 +331,7 @@ "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", "account_tokens_table_last_access_header": "Laatste toegang", "account_tokens_table_expires_header": "Verloopt op", - "account_tokens_table_copy_to_clipboard": "Kopieer naar klembord", + "common_copy_to_clipboard": "Kopieer naar klembord", "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd", "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen", "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json index 5e6bcbe5..9dea2b8a 100644 --- a/web/public/static/langs/pl.json +++ b/web/public/static/langs/pl.json @@ -107,7 +107,7 @@ "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", "subscribe_dialog_login_password_label": "Hasło", "publish_dialog_button_cancel": "Anuluj", - "subscribe_dialog_login_button_back": "Powrót", + "common_back": "Powrót", "subscribe_dialog_login_button_login": "Zaloguj się", "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", "subscribe_dialog_error_user_anonymous": "anonim", @@ -253,7 +253,7 @@ "account_tokens_table_expires_header": "Termin ważności", "account_tokens_table_never_expires": "Bezterminowy", "account_tokens_table_current_session": "Aktualna sesja przeglądarki", - "account_tokens_table_copy_to_clipboard": "Kopiuj do schowka", + "common_copy_to_clipboard": "Kopiuj do schowka", "account_tokens_table_copied_to_clipboard": "Token został skopiowany", "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", "account_tokens_table_create_token_button": "Utwórz token dostępowy", diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 196baf4f..bf753c9a 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -144,7 +144,7 @@ "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.", "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"", "subscribe_dialog_login_password_label": "Palavra-passe", - "subscribe_dialog_login_button_back": "Voltar", + "common_back": "Voltar", "subscribe_dialog_login_button_login": "Autenticar", "subscribe_dialog_error_user_anonymous": "anónimo", "prefs_notifications_title": "Notificações", diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 79622be3..acf5bca0 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -93,7 +93,7 @@ "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", "subscribe_dialog_login_password_label": "Senha", - "subscribe_dialog_login_button_back": "Voltar", + "common_back": "Voltar", "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima", "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", "prefs_notifications_delete_after_title": "Apagar notificações", diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 42025e43..9633d97d 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -98,7 +98,7 @@ "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", "subscribe_dialog_error_user_anonymous": "анонимный пользователь", @@ -206,7 +206,7 @@ "account_basics_tier_free": "Бесплатный", "account_tokens_dialog_title_create": "Создать токен доступа", "account_tokens_dialog_title_delete": "Удалить токен доступа", - "account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена", + "common_copy_to_clipboard": "Скопировать в буфер обмена", "account_tokens_dialog_button_cancel": "Отмена", "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index 9e9dfc20..31e809c5 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -95,14 +95,14 @@ "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com", "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i dokumentationen .", "publish_dialog_button_send": "Skicka", - "subscribe_dialog_login_button_back": "Tillbaka", + "common_back": "Tillbaka", "account_basics_tier_free": "Gratis", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", "account_delete_title": "Ta bort konto", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", "account_upgrade_dialog_button_cancel": "Avbryt", - "account_tokens_table_copy_to_clipboard": "Kopiera till urklipp", + "common_copy_to_clipboard": "Kopiera till urklipp", "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat", "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i dokumentationen.", "account_tokens_table_create_token_button": "Skapa åtkomsttoken", diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index 8bdb88d3..3eccda88 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -34,7 +34,7 @@ "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.", "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_password_label": "Parola", - "subscribe_dialog_login_button_back": "Geri", + "common_back": "Geri", "subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_anonymous": "anonim", @@ -268,7 +268,7 @@ "account_tokens_table_token_header": "Belirteç", "account_tokens_table_label_header": "Etiket", "account_tokens_table_current_session": "Geçerli tarayıcı oturumu", - "account_tokens_table_copy_to_clipboard": "Panoya kopyala", + "common_copy_to_clipboard": "Panoya kopyala", "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı", "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", "account_tokens_table_create_token_button": "Erişim belirteci oluştur", diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 686a3d3e..8683769e 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -53,7 +53,7 @@ "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", "subscribe_dialog_subscribe_base_url_label": "URL служби", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index 4da4328c..2db95f56 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -103,7 +103,7 @@ "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", "subscribe_dialog_login_username_label": "用户名,例如 phil", "subscribe_dialog_login_password_label": "密码", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登录", "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", "subscribe_dialog_error_user_anonymous": "匿名", @@ -333,7 +333,7 @@ "account_tokens_table_expires_header": "过期", "account_tokens_table_never_expires": "永不过期", "account_tokens_table_current_session": "当前浏览器会话", - "account_tokens_table_copy_to_clipboard": "复制到剪贴板", + "common_copy_to_clipboard": "复制到剪贴板", "account_tokens_table_copied_to_clipboard": "已复制访问令牌", "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", "account_tokens_table_create_token_button": "创建访问令牌", diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index c1b4de81..aafc28e0 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -70,7 +70,7 @@ "subscribe_dialog_subscribe_button_subscribe": "訂閱", "emoji_picker_search_clear": "清除", "subscribe_dialog_login_password_label": "密碼", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登入", "prefs_notifications_delete_after_never": "從不", "prefs_users_add_button": "新增使用者", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 243286b4..21b3f810 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,7 +1,7 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, + accountPasswordUrl, accountPhoneUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, @@ -299,6 +299,43 @@ class AccountApi { return await response.json(); // May throw SyntaxError } + async verifyPhone(phoneNumber) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Sending phone verification ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber + }) + }); + } + + async checkVerifyPhone(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Checking phone verification code ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + code: code + }) + }); + } + + async deletePhoneNumber(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Deleting phone number ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber + }) + }); + } + async sync() { try { if (!session.token()) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 25b4a459..6e044913 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -27,6 +27,7 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; +export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b5294cd5..4c19a291 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -325,37 +325,183 @@ 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 handleAdd = () => { - + const handleDialogOpen = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); }; - const handleClick = () => { - + const handleDialogClose = () => { + setDialogOpen(false); }; - const handleDelete = () => { - + 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; + } + return (
- {account?.phone_numbers.map(p => - navigator.clipboard.writeText(p.number)} - onDelete={() => handleDelete(p.number)} - /> + {account?.phone_numbers?.map(phoneNumber => + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> )} - handleAdd()}> + {!account?.phone_numbers && + {t("account_basics_phone_numbers_no_phone_numbers_yet")} + } +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> +
) }; +const AddPhoneNumberDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [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); + } else { + props.onClose(); + } + }; + + const verifyPhone = async () => { + try { + setSending(true); + await accountApi.verifyPhone(phoneNumber); + 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.checkVerifyPhone(phoneNumber, code); + props.onClose(); + } catch (e) { + console.log(`[Account] Error confirming verification`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + return ( + + {t("account_basics_phone_numbers_dialog_title")} + + + {t("account_basics_phone_numbers_dialog_description")} + + {!verificationCodeSent && + setPhoneNumber(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + /> + } + {verificationCodeSent && + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} + variant="standard" + /> + } + + + + + + + ); +}; + + const Stats = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); @@ -594,7 +740,7 @@ const TokensTable = (props) => { {token.token.slice(0, 12)} ... - + handleCopy(token.token)}> diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 4fd4f8c4..95f1c473 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -288,7 +288,7 @@ const LoginPage = (props) => { /> - + diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c4d665e0..0b91b1b1 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -300,11 +300,9 @@ const TierCard = (props) => { {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} - {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} - {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH && From 539ba43cd1e2a4975fddc0fbf6a9b10ef86419a0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 13 May 2023 12:26:14 -0400 Subject: [PATCH 10/20] WIP twilio --- cmd/serve.go | 3 --- server/config.go | 1 - server/server.go | 14 ++++++++++---- server/server_twilio.go | 10 ++++++++++ server/util.go | 8 ++++++++ server/visitor.go | 6 +++++- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 9e020576..28081a02 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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-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"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), @@ -171,7 +170,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") - visitorCallDailyLimit := c.Int("visitor-call-daily-limit") behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") @@ -334,7 +332,6 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorCallDailyLimit = visitorCallDailyLimit conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy conf.StripeSecretKey = stripeSecretKey diff --git a/server/config.go b/server/config.go index 3fd88e80..80eb6132 100644 --- a/server/config.go +++ b/server/config.go @@ -129,7 +129,6 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration - VisitorCallDailyLimit int VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int diff --git a/server/server.go b/server/server.go index ce87f979..505de4b8 100644 --- a/server/server.go +++ b/server/server.go @@ -691,12 +691,18 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, errHTTPTooManyRequestsLimitMessages.With(t) } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if call != "" && !vrate.CallAllowed() { - return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } else if call != "" { + call, err = s.convertPhoneNumber(v.User(), call) + if err != nil { + return nil, errHTTPBadRequestInvalidPhoneNumber.With(t) + } + if !vrate.CallAllowed() { + return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } } // FIXME check allowed phone numbers - + if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } @@ -893,7 +899,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi call = readParam(r, "x-call", "call") if call != "" && s.config.TwilioAccount == "" { return false, false, "", "", false, errHTTPBadRequestTwilioDisabled - } else if call != "" && !phoneNumberRegex.MatchString(call) { + } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") diff --git a/server/server_twilio.go b/server/server_twilio.go index a6b91097..128ae5ef 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -31,6 +31,16 @@ const ( ` ) +func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, error) { + if u == nil { + return "", fmt.Errorf("user is nil") + } + if s.config.TwilioPhoneNumberConverter == nil { + return phoneNumber, nil + } + return s.config.TwilioPhoneNumberConverter(u, phoneNumber) +} + 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{} diff --git a/server/util.go b/server/util.go index f0b49d28..a3a45547 100644 --- a/server/util.go +++ b/server/util.go @@ -18,6 +18,14 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { if value == "" { return defaultValue } + return toBool(value) +} + +func isBoolValue(value string) bool { + return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false" +} + +func toBool(value string) bool { return value == "1" || value == "yes" || value == "true" } diff --git a/server/visitor.go b/server/visitor.go index 4895c3f0..e4c06f66 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,6 +24,10 @@ const ( // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. // This number is zero, and changing it may have unintended consequences in the web app, or otherwise visitorDefaultReservationsLimit = int64(0) + + // visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make. + // This number is zero, because phone numbers have to be verified first. + visitorDefaultCallsLimit = int64(0) ) // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter @@ -444,7 +448,7 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), - CallLimit: int64(conf.VisitorCallDailyLimit), + CallLimit: visitorDefaultCallsLimit, ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, From 4b9e0c5c3817ca4ed716dab6f12f473e168eaa15 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 20:42:43 -0400 Subject: [PATCH 11/20] Phone number verification in publishing --- server/errors.go | 4 +++- server/server.go | 16 +++++++--------- server/server_twilio.go | 21 +++++++++++++++++---- web/src/components/Account.js | 8 ++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/server/errors.go b/server/errors.go index ee5223bf..a42641b4 100644 --- a/server/errors.go +++ b/server/errors.go @@ -108,8 +108,10 @@ var ( errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestTwilioDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/publish/#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} 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} diff --git a/server/server.go b/server/server.go index 430fa5cb..08cf08d3 100644 --- a/server/server.go +++ b/server/server.go @@ -707,17 +707,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) } else if call != "" { - call, err = s.convertPhoneNumber(v.User(), call) - if err != nil { - return nil, errHTTPBadRequestInvalidPhoneNumber.With(t) - } - if !vrate.CallAllowed() { + 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) } } - - // FIXME check allowed phone numbers - if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } @@ -741,6 +738,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e "message_firebase": firebase, "message_unifiedpush": unifiedpush, "message_email": email, + "message_call": call, }) if ev.IsTrace() { ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message") @@ -913,7 +911,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } call = readParam(r, "x-call", "call") if call != "" && s.config.TwilioAccount == "" { - return false, false, "", "", false, errHTTPBadRequestTwilioDisabled + return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } diff --git a/server/server_twilio.go b/server/server_twilio.go index 128ae5ef..2c3d0a3e 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -31,14 +31,27 @@ const ( ` ) -func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, error) { +func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { if u == nil { - return "", fmt.Errorf("user is nil") + return "", errHTTPBadRequestAnonymousCallsNotAllowed } - if s.config.TwilioPhoneNumberConverter == nil { + 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 } - return s.config.TwilioPhoneNumberConverter(u, phoneNumber) + for _, p := range phoneNumbers { + if p == phoneNumber { + return phoneNumber, nil + } + } + return "", errHTTPBadRequestPhoneNumberNotVerified } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 706ac02a..28d24a38 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -359,6 +359,14 @@ const PhoneNumbers = () => { return null; } + if (account?.limits.calls === 0) { + return ( + {t("account_basics_phone_numbers_title")}{config.enable_payments && }} description={t("account_basics_phone_numbers_description")}> + {t("account_usage_calls_none")} + + ) + } + return (
From deb4f2485690ac7ca3dad5c4f8995cccfc411788 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 22:06:43 -0400 Subject: [PATCH 12/20] Cont'd, getting there --- docs/publish.md | 57 ++++++++++++++++++---------------------- server/server_metrics.go | 10 ------- server/server_twilio.go | 39 +++++++++++++-------------- 3 files changed, 44 insertions(+), 62 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index ef4c9a86..2491671f 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2698,19 +2698,25 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt ## 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**, by specifying a phone number a header. +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). 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 if you only have one verified phone number. +**Phone numbers have to be previously verified** (via the web app), so this feature is **only available to authenticated users**. +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. + +!!! info + 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). On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. === "Command line (curl)" ``` curl \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ -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 @@ -2719,6 +2725,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "ntfy CLI" ``` ntfy publish \ + --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ --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." ``` @@ -2727,6 +2734,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl ``` 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, and call 0118 999 881 999 119 725 3 for help. @@ -2738,9 +2746,8 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl method: 'POST', 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', - 'Priority': 'high' + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'Call': '+12223334444' } }) ``` @@ -2748,10 +2755,9 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "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") + strings.NewReader("Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.")) + req.Header.Set("Call", "+12223334444") + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") http.DefaultClient.Do(req) ``` @@ -2761,12 +2767,10 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl 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" + Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Call = "+12223334444" } - 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." } Invoke-RestMethod @Request ``` @@ -2774,11 +2778,10 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Python" ``` python requests.post("https://ntfy.sh/alerts", - data="Unknown login from 5.31.23.83 to backups.example.com", + data="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", - "Priority": "high" + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", + "Call": "+12223334444" }) ``` @@ -2789,21 +2792,13 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl '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' + "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . + "Call: +12223334444", + 'content' => 'Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.' ] ])); ``` -Here's what that looks like in Google Mail: - -
- ![e-mail notification](static/img/screenshot-email.png){ width=600 } -
E-mail notification
-
- ## 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. diff --git a/server/server_metrics.go b/server/server_metrics.go index d2e6f1c0..88fa9f15 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,8 +15,6 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter - metricSMSSentSuccess prometheus.Counter - metricSMSSentFailure prometheus.Counter metricCallsMadeSuccess prometheus.Counter metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter @@ -61,12 +59,6 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_emails_received_failure", }) - metricSMSSentSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_sms_sent_success", - }) - metricSMSSentFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_sms_sent_failure", - }) metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_calls_made_success", }) @@ -111,8 +103,6 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, - metricSMSSentSuccess, - metricSMSSentFailure, metricCallsMadeSuccess, metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, diff --git a/server/server_twilio.go b/server/server_twilio.go index 2c3d0a3e..e5438133 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -15,19 +15,21 @@ import ( ) const ( - twilioMessageFooterFormat = "This message was sent by %s via %s" - twilioCallEndpoint = "Calls.json" - twilioCallFormat = ` + twilioCallEndpoint = "Calls.json" + twilioCallFormat = ` - You have a message from notify on topic %s. Message: - - %s - - End message. - - %s - + + You have a notification from notify on topic %s. Message: + + %s + + End message. + + This message was sent by user %s. It will be repeated up to five times. + + + Goodbye. ` ) @@ -55,7 +57,11 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, * } 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))) + 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) @@ -186,15 +192,6 @@ func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url return string(response), nil } -func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil! - topicURL := s.config.BaseURL + "/" + m.Topic - sender := m.Sender.String() - if u != nil { - sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender) - } - return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) -} - func xmlEscapeText(text string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(text)) From 7c574d73de5a86a88ccca555398349cb58699244 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 14:15:58 -0400 Subject: [PATCH 13/20] Cont'd Twilio stuff --- cmd/serve.go | 6 +- cmd/tier.go | 2 +- log/event.go | 41 ++--- server/config.go | 5 +- server/server.go | 11 +- server/server.yml | 12 +- server/server_account.go | 8 +- server/server_middleware.go | 9 ++ server/server_twilio.go | 107 +++++-------- server/server_twilio_test.go | 226 +++++++++++++++++----------- server/types.go | 3 - web/public/static/langs/en.json | 2 +- web/src/app/AccountApi.js | 12 +- web/src/app/utils.js | 1 + web/src/components/Account.js | 4 +- web/src/components/PublishDialog.js | 27 ---- 16 files changed, 240 insertions(+), 236 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 28081a02..4e123e93 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -73,7 +73,7 @@ var flagsServe = append( 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 and text messages"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), @@ -217,8 +217,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") { - return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number 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 diff --git a/cmd/tier.go b/cmd/tier.go index e77927c5..f1c8ddcb 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,7 +18,7 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 - defaultCallLimit = 10 + defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" diff --git a/log/event.go b/log/event.go index ccde4126..b4b8f59f 100644 --- a/log/event.go +++ b/log/event.go @@ -41,34 +41,34 @@ func newEvent() *Event { // Fatal logs the event as FATAL, and exits the program with exit code 1 func (e *Event) Fatal(message string, v ...any) { - e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...) + e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...) fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr os.Exit(1) } // Error logs the event with log level error -func (e *Event) Error(message string, v ...any) { - e.maybeLog(ErrorLevel, message, v...) +func (e *Event) Error(message string, v ...any) *Event { + return e.Log(ErrorLevel, message, v...) } // Warn logs the event with log level warn -func (e *Event) Warn(message string, v ...any) { - e.maybeLog(WarnLevel, message, v...) +func (e *Event) Warn(message string, v ...any) *Event { + return e.Log(WarnLevel, message, v...) } // Info logs the event with log level info -func (e *Event) Info(message string, v ...any) { - e.maybeLog(InfoLevel, message, v...) +func (e *Event) Info(message string, v ...any) *Event { + return e.Log(InfoLevel, message, v...) } // Debug logs the event with log level debug -func (e *Event) Debug(message string, v ...any) { - e.maybeLog(DebugLevel, message, v...) +func (e *Event) Debug(message string, v ...any) *Event { + return e.Log(DebugLevel, message, v...) } // Trace logs the event with log level trace -func (e *Event) Trace(message string, v ...any) { - e.maybeLog(TraceLevel, message, v...) +func (e *Event) Trace(message string, v ...any) *Event { + return e.Log(TraceLevel, message, v...) } // Tag adds a "tag" field to the log event @@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event { return e } +// FieldIf adds a custom field and value to the log event if the given level is loggable +func (e *Event) FieldIf(key string, value any, level Level) *Event { + if e.Loggable(level) { + return e.Field(key, value) + } + return e +} + // Fields adds a map of fields to the log event func (e *Event) Fields(fields Context) *Event { if e.fields == nil { @@ -138,7 +146,7 @@ func (e *Event) With(contexters ...Contexter) *Event { // to determine if they match. This is super complicated, but required for efficiency. func (e *Event) Render(l Level, message string, v ...any) string { appliedContexters := e.maybeApplyContexters() - if !e.shouldLog(l) { + if !e.Loggable(l) { return "" } e.Message = fmt.Sprintf(message, v...) @@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string { return e.String() } -// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string -func (e *Event) maybeLog(l Level, message string, v ...any) { +// Log logs the event to the defined output, or does nothing if Render returns an empty string +func (e *Event) Log(l Level, message string, v ...any) *Event { if m := e.Render(l, message, v...); m != "" { log.Println(m) } + return e } // Loggable returns true if the given log level is lower or equal to the current log level @@ -199,10 +208,6 @@ func (e *Event) String() string { return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) } -func (e *Event) shouldLog(l Level) bool { - return e.globalLevelWithOverride() <= l -} - func (e *Event) globalLevelWithOverride() Level { mu.RLock() l, ov := level, overrides diff --git a/server/config.go b/server/config.go index 80eb6132..376862a1 100644 --- a/server/config.go +++ b/server/config.go @@ -47,7 +47,6 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -106,10 +105,10 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioMessagingBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string + TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string MetricsEnable bool @@ -190,7 +189,7 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests + TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", diff --git a/server/server.go b/server/server.go index 08cf08d3..fb448015 100644 --- a/server/server.go +++ b/server/server.go @@ -91,6 +91,7 @@ var ( apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" apiAccountPhonePath = "/v1/account/phone" + apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -463,12 +464,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath { + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v) } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) - } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) + 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.withAccountSync(s.handleAccountPhoneNumberDelete))(w, r, v) + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { @@ -910,7 +911,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") - if call != "" && s.config.TwilioAccount == "" { + 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 diff --git a/server/server.yml b/server/server.yml index f11ad362..6728d6a4 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,7 +144,7 @@ # smtp-server-domain: # smtp-server-addr-prefix: -# If enabled, ntfy can send SMS text messages and do voice calls via Twilio, and the "X-SMS" and "X-Call" headers. +# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. # # twilio-account: # twilio-auth-token: @@ -225,17 +225,11 @@ # visitor-request-limit-exempt-hosts: "" # Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset -# every day at midnight UTC. If the limit is not set (or set to zero), the request limit (see above) -# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. +# every day at midnight UTC. If the limit is not set (or set to zero), the request +# limit (see above) governs the upper limit. # # visitor-message-daily-limit: 0 -# Rate limiting: Daily limit of SMS and calls per visitor and day. The limit is reset every day -# at midnight UTC. SMS and calls are only supported if the twilio-settings are properly configured. -# -# visitor-sms-daily-limit: 10 -# visitor-call-daily-limit: 10 - # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled diff --git a/server/server_account.go b/server/server_account.go index eb1c768f..2330eab8 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -521,7 +521,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi return nil } -func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { @@ -545,13 +545,13 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } // 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.verifyPhone(v, r, req.Number); err != nil { + if err := s.verifyPhoneNumber(v, r, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { @@ -560,7 +560,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { return errHTTPBadRequestPhoneNumberInvalid } - if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { + 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") diff --git a/server/server_middleware.go b/server/server_middleware.go index e0435bb2..0e4aff7c 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -85,6 +85,15 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc { }) } +func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.TwilioAccount == "" { + return errHTTPNotFound + } + return next(w, r, v) + } +} + func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/server_twilio.go b/server/server_twilio.go index e5438133..f8067490 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/xml" "fmt" - "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/user" "heckel.io/ntfy/util" @@ -15,24 +14,26 @@ import ( ) const ( - twilioCallEndpoint = "Calls.json" - twilioCallFormat = ` + twilioCallFormat = ` - + You have a notification from notify on topic %s. Message: %s End message. - This message was sent by user %s. It will be repeated up to five times. + This message was sent by user %s. It will be repeated up to three times. Goodbye. ` ) +// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified +// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. +// If the user is anonymous, it will return an error. func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { if u == nil { return "", errHTTPBadRequestAnonymousCallsNotAllowed @@ -66,11 +67,38 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.twilioMessagingRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) + 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) verifyPhone(v *visitor, r *http.Request, phoneNumber string) error { - logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") +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 string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") data := url.Values{} data.Set("To", phoneNumber) data.Set("Channel", "sms") @@ -86,21 +114,16 @@ func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) er return err } response, err := io.ReadAll(resp.Body) - ev := logvr(v, r).Tag(tagTwilio) if err != nil { ev.Err(err).Warn("Error sending Twilio phone verification request") 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") - } + ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response") return nil } -func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error { - logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") +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) @@ -111,10 +134,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code } 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 @@ -144,54 +163,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code return nil } -func (s *Server) twilioMessagingRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { - logContext := log.Context{ - "twilio_from": s.config.TwilioFromNumber, - "twilio_to": to, - } - ev := logvrm(v, r, m).Tag(tagTwilio).Fields(logContext) - if ev.IsTrace() { - ev.Field("twilio_body", body).Trace("Sending Twilio request") - } else if ev.IsDebug() { - ev.Debug("Sending Twilio request") - } - response, err := s.performTwilioMessagingRequestInternal(endpoint, data) - if err != nil { - ev. - Field("twilio_body", body). - Field("twilio_response", response). - Err(err). - Warn("Error sending Twilio request") - minc(mfailure) - return - } - if ev.IsTrace() { - ev.Field("twilio_response", response).Trace("Received successful Twilio response") - } else if ev.IsDebug() { - ev.Debug("Received successful Twilio response") - } - minc(msuccess) -} - -func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioMessagingBaseURL, s.config.TwilioAccount, endpoint) - 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 xmlEscapeText(text string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(text)) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 133138f3..5b320959 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -11,37 +11,108 @@ import ( "testing" ) -func TestServer_Twilio_SMS(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +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, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+9.9.9.9+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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() + defer twilioCallsServer.Close() - c := newTestConfig(t) - c.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c := newTestConfigWithAuthFile(t) + c.TwilioVerifyBaseURL = twilioVerifyServer.URL + c.TwilioCallsBaseURL = twilioCallsServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" - c.VisitorSMSDailyLimit = 1 + c.TwilioVerifyService = "VA1234567890" s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "SMS": "+11122233344", + // 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"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), }) - require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + 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_SMS_With_User(t *testing.T) { +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() { @@ -49,16 +120,15 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { } body, err := io.ReadAll(r.Body) require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + 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, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -68,62 +138,26 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "pro", MessageLimit: 10, - SMSLimit: 1, + 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 request with user - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "SMS": "+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, "test", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) - - // Second one should fail due to rate limits - response = request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "SMS": "+11122233344", - }) - require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_Call(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ethis+message+has%26%23xA%3Ba+new+line+and+%26lt%3Bbrackets%26gt%3B%21%26%23xA%3Band+%26%2334%3Bquotes+and+other+%26%2339%3Bquotes%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+9.9.9.9+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() - - c := newTestConfig(t) - c.TwilioMessagingBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" - c.VisitorCallDailyLimit = 1 - s := newTestServer(t, c) - - body := `this message has -a new line and ! -and "quotes and other 'quotes` - response := request(t, s, "POST", "/mytopic", body, map[string]string{ - "x-call": "+11122233344", - }) - require.Equal(t, "this message has\na new line and !\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) waitFor(t, func() bool { return called.Load() }) } -func TestServer_Twilio_Call_With_User(t *testing.T) { +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() { @@ -133,13 +167,44 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { 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%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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.TwilioMessagingBaseURL = twilioServer.URL + 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" @@ -155,19 +220,16 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { require.Nil(t, s.userManager.ChangeTier("phil", "pro")) // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + response := request(t, s, "POST", "/mytopic", "test", 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() - }) + require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioMessagingBaseURL = "https://127.0.0.1" + c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -176,29 +238,21 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "x-call": "+invalid", }) - require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) } -func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { +func TestServer_Twilio_Call_Anonymous(t *testing.T) { c := newTestConfig(t) - c.TwilioMessagingBaseURL = "https://127.0.0.1" + 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-sms": "+invalid", + "x-call": "+123123", }) - require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_SMS_Unconfigured(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-sms": "+1234", - }) - require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_Unconfigured(t *testing.T) { @@ -206,5 +260,5 @@ func TestServer_Twilio_Call_Unconfigured(t *testing.T) { response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "x-call": "+1234", }) - require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) } diff --git a/server/types.go b/server/types.go index cbf1df9a..8fd75176 100644 --- a/server/types.go +++ b/server/types.go @@ -326,7 +326,6 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` - SMS int64 `json:"sms"` Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` @@ -340,8 +339,6 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` - SMS int64 `json:"sms"` - SMSRemaining int64 `json:"sms_remaining"` Calls int64 `json:"calls"` CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 7d8affc0..f2120e5f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -130,7 +130,7 @@ "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", "publish_dialog_call_label": "Phone call", - "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444", + "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444, or 'yes'", "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 21b3f810..b5bfcd29 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,7 +1,7 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, accountPhoneUrl, + accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, @@ -299,8 +299,8 @@ class AccountApi { return await response.json(); // May throw SyntaxError } - async verifyPhone(phoneNumber) { - const url = accountPhoneUrl(config.base_url); + async verifyPhoneNumber(phoneNumber) { + const url = accountPhoneVerifyUrl(config.base_url); console.log(`[AccountApi] Sending phone verification ${url}`); await fetchOrThrow(url, { method: "PUT", @@ -311,11 +311,11 @@ class AccountApi { }); } - async checkVerifyPhone(phoneNumber, code) { + async addPhoneNumber(phoneNumber, code) { const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Checking phone verification code ${url}`); + console.log(`[AccountApi] Adding phone number with verification code ${url}`); await fetchOrThrow(url, { - method: "POST", + method: "PUT", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ number: phoneNumber, diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6e044913..346df37f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -28,6 +28,7 @@ export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/ac export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; +export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 28d24a38..b4a378e6 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -432,7 +432,7 @@ const AddPhoneNumberDialog = (props) => { const verifyPhone = async () => { try { setSending(true); - await accountApi.verifyPhone(phoneNumber); + await accountApi.verifyPhoneNumber(phoneNumber); setVerificationCodeSent(true); } catch (e) { console.log(`[Account] Error sending verification`, e); @@ -449,7 +449,7 @@ const AddPhoneNumberDialog = (props) => { const checkVerifyPhone = async () => { try { setSending(true); - await accountApi.checkVerifyPhone(phoneNumber, code); + await accountApi.addPhoneNumber(phoneNumber, code); props.onClose(); } catch (e) { console.log(`[Account] Error confirming verification`, e); diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index c410f19d..0353abe7 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,7 +45,6 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); - const [sms, setSms] = useState(""); const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -54,7 +53,6 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); - const [showSms, setShowSms] = useState(false); const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); @@ -128,9 +126,6 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } - if (sms.trim()) { - url.searchParams.append("sms", sms.trim()); - } if (call.trim()) { url.searchParams.append("call", call.trim()); } @@ -416,27 +411,6 @@ const PublishDialog = (props) => { /> } - {showSms && - { - setSms(""); - setShowSms(false); - }}> - setSms(ev.target.value)} - disabled={disabled} - type="tel" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_sms_label") - }} - /> - - } {showCall && { setCall(""); @@ -562,7 +536,6 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} From 5e18ced7d2ba600d13cd97d3ad0ca77beee8aae3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 15:02:53 -0400 Subject: [PATCH 14/20] Docs --- docs/config.md | 17 +++++++++++++++ docs/publish.md | 29 +++++++++++++++++++------- docs/static/audio/ntfy-phone-call.mp3 | Bin 0 -> 59728 bytes docs/static/audio/ntfy-phone-call.ogg | Bin 0 -> 51664 bytes 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 docs/static/audio/ntfy-phone-call.mp3 create mode 100644 docs/static/audio/ntfy-phone-call.ogg diff --git a/docs/config.md b/docs/config.md index fa599388..353a9d03 100644 --- a/docs/config.md +++ b/docs/config.md @@ -814,6 +814,7 @@ ntfy tier add \ --message-limit=10000 \ --message-expiry-duration=24h \ --email-limit=50 \ + --call-limit=10 \ --reservation-limit=10 \ --attachment-file-size-limit=100M \ --attachment-total-size-limit=1G \ @@ -854,6 +855,22 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" billing-contact: "phil@example.com" ``` +## Phone calls +ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a phone call provider. If phone calls are enabled, +users can verify and add a phone number, and then receive phone calls when publish a message with 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, and then assign it to a user. +Users may then use the `X-Call` header to receive a phone call when publishing a message. + ## Rate limiting !!! info Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. diff --git a/docs/publish.md b/docs/publish.md index 2491671f..98f3e876 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2718,7 +2718,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl curl \ -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ -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." \ + -d "Your garage seems to be on fire. You should probably check that out." \ ntfy.sh/alerts ``` @@ -2727,7 +2727,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl ntfy publish \ --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ --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." + alerts "Your garage seems to be on fire. You should probably check that out." ``` === "HTTP" @@ -2737,14 +2737,14 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 Call: +12223334444 - Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help. + 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, and call 0118 999 881 999 119 725 3 for help.", + body: "Your garage seems to be on fire. You should probably check that out.", headers: { 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', 'Call': '+12223334444' @@ -2755,7 +2755,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "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, and call 0118 999 881 999 119 725 3 for help.")) + 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) @@ -2770,7 +2770,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" Call = "+12223334444" } - 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." + Body = "Your garage seems to be on fire. You should probably check that out." } Invoke-RestMethod @Request ``` @@ -2778,7 +2778,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Python" ``` python requests.post("https://ntfy.sh/alerts", - data="Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", + data="Your garage seems to be on fire. You should probably check that out.", headers={ "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "Call": "+12223334444" @@ -2794,11 +2794,24 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl "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, and call 0118 999 881 999 119 725 3 for help.' + 'content' => 'Your garage seems to be on fire. You should probably check that out.' ] ])); ``` +Here's what a phone call from ntfy sounds like: + + + +Audio transcript: + +> You have a notification from ntfy on topic alerts. +> Message: Your garage seems to be on fire. You should probably check that out. End message. +> This message was sent by user phil. It will be repeated up to three times. + ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0cace65f3e1f56d6f4b5208053133afa28ee7139 GIT binary patch literal 59728 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%0D}4K8bvKvQtIaa1I-a`dPavLihx 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

-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

J5=$m~DTl_B_+jwB0zU>F}<6)|Bck1^)2^V1M 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 J6onY@e*kf-c%lFR literal 0 HcmV?d00001 From 496d6e74b0da63a9b2f246b1d806a70c3989d371 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 15:12:18 -0400 Subject: [PATCH 15/20] Staticcheck --- server/types.go | 4 ---- user/manager.go | 1 - 2 files changed, 5 deletions(-) diff --git a/server/types.go b/server/types.go index 8fd75176..a1d18926 100644 --- a/server/types.go +++ b/server/types.go @@ -455,7 +455,3 @@ type apiStripeSubscriptionDeletedEvent struct { ID string `json:"id"` Customer string `json:"customer"` } - -type apiTwilioVerifyResponse struct { - Status string `json:"status"` -} diff --git a/user/manager.go b/user/manager.go index 8a9acf05..c57ede58 100644 --- a/user/manager.go +++ b/user/manager.go @@ -7,7 +7,6 @@ import ( "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" "heckel.io/ntfy/log" From 2c81773d01094be3e4fbe10add54c166469acb37 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 22:27:48 -0400 Subject: [PATCH 16/20] Add call verification --- docs/config.md | 4 +-- docs/publish.md | 48 ++++++++++++++++---------- docs/static/img/web-phone-verify.png | Bin 0 -> 22959 bytes go.sum | 18 ---------- server/errors.go | 1 + server/server_account.go | 13 +++---- server/server_twilio.go | 13 +++---- server/types.go | 9 +++-- web/public/static/langs/en.json | 7 ++-- web/src/app/AccountApi.js | 5 +-- web/src/components/Account.js | 49 +++++++++++++++++---------- 11 files changed, 93 insertions(+), 74 deletions(-) create mode 100644 docs/static/img/web-phone-verify.png diff --git a/docs/config.md b/docs/config.md index 353a9d03..d6f6e408 100644 --- a/docs/config.md +++ b/docs/config.md @@ -868,8 +868,8 @@ are the easiest), and then configure the following options: * `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, and then assign it to a user. -Users may then use the `X-Call` header to receive a phone call when publishing a message. +After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), +and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. ## Rate limiting !!! info diff --git a/docs/publish.md b/docs/publish.md index 98f3e876..3cca6fc6 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2702,16 +2702,26 @@ You can use ntfy to call a phone and **read the message out loud using text-to-s 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), so this feature is **only available to authenticated users**. -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. +**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. + +

+ ![e-mail publishing](static/img/web-phone-verify.png) +
Phone number verification in the web app
+
+ +As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll +be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). !!! info - 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). + 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. -On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. +Here's how you use it: === "Command line (curl)" ``` @@ -3431,17 +3441,18 @@ There are a few limitations to the API to prevent abuse and to keep the server h are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, but just in case, let's list them all: -| Limit | Description | -|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | -| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | -| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | -| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | -| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | -| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | -| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | -| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | +| Limit | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | +| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | +| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. @@ -3470,6 +3481,7 @@ table in their canonical form. | `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | +| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png new file mode 100644 index 0000000000000000000000000000000000000000..335aeef13848541002456ebbf93ebb17669d04e9 GIT binary patch literal 22959 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 h - You have a notification from notify on topic %s. Message: + You have a message from notify on topic %s. Message: %s - End message. + End of message. - This message was sent by user %s. It will be repeated up to three times. + This message was sent by user %s. It will be repeated three times. + To unsubscribe from calls like this, remove your phone number in the notify web app. Goodbye. @@ -97,11 +98,11 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) { return string(response), nil } -func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber string) error { - ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") +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", "sms") + 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 { diff --git a/server/types.go b/server/types.go index a1d18926..3b733678 100644 --- a/server/types.go +++ b/server/types.go @@ -311,9 +311,14 @@ type apiAccountTokenResponse struct { Expires int64 `json:"expires,omitempty"` // Unix timestamp } -type apiAccountPhoneNumberRequest struct { +type apiAccountPhoneNumberVerifyRequest struct { + Number string `json:"number"` + Channel string `json:"channel"` +} + +type apiAccountPhoneNumberAddRequest struct { Number string `json:"number"` - Code string `json:"code,omitempty"` // Only supplied in "verify" call + Code string `json:"code,omitempty"` } type apiAccountTier struct { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index f2120e5f..588a1f9f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -188,17 +188,20 @@ "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_phone_numbers_title": "Phone numbers", - "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Adding it will send a verification SMS to your phone.", + "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_send_verification_button": "Send verification", + "account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Call me", "account_basics_phone_numbers_dialog_code_label": "Verification code", "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456", "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Call", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index b5bfcd29..8908f306 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -299,14 +299,15 @@ class AccountApi { return await response.json(); // May throw SyntaxError } - async verifyPhoneNumber(phoneNumber) { + 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 + number: phoneNumber, + channel: channel }) }); } diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b4a378e6..b480ea6b 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,13 +1,13 @@ import * as React from 'react'; import {useContext, useState} from 'react'; import { - Alert, + Alert, ButtonGroup, CardActions, CardContent, Chip, - FormControl, + FormControl, FormControlLabel, InputLabel, LinearProgress, Link, - Portal, + Portal, Radio, RadioGroup, Select, Snackbar, Stack, @@ -47,12 +47,14 @@ import {AccountContext} from "./App"; import DialogFooter from "./DialogFooter"; import {Paragraph} from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import {ContentCopy, Public} from "@mui/icons-material"; +import {Check, ContentCopy, DeleteForever, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {ProChip} from "./SubscriptionPopup"; import AddIcon from "@mui/icons-material/Add"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; const Account = () => { if (!session.exists()) { @@ -408,6 +410,7 @@ 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); @@ -432,7 +435,7 @@ const AddPhoneNumberDialog = (props) => { const verifyPhone = async () => { try { setSending(true); - await accountApi.verifyPhoneNumber(phoneNumber); + await accountApi.verifyPhoneNumber(phoneNumber, channel); setVerificationCodeSent(true); } catch (e) { console.log(`[Account] Error sending verification`, e); @@ -471,18 +474,26 @@ const AddPhoneNumberDialog = (props) => { {t("account_basics_phone_numbers_dialog_description")} {!verificationCodeSent && - setPhoneNumber(ev.target.value)} - fullWidth - inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} - variant="standard" - /> +
+ setPhoneNumber(ev.target.value)} + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + sx={{ flexGrow: 1 }} + /> + + + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} /> + + +
} {verificationCodeSent && { From 79a3259c867d85c60dcc6fa21d77479805284f84 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 22:30:38 -0400 Subject: [PATCH 17/20] Language file --- web/public/static/langs/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 588a1f9f..04233b79 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -256,7 +256,7 @@ "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 daily phone calls", + "account_upgrade_dialog_tier_features_no_calls": "No phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", From ac029c389ed117d62c806b85c675c6dee57a0e0f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 10:39:15 -0400 Subject: [PATCH 18/20] Self-review --- docs/config.md | 4 +- docs/publish.md | 2 +- go.mod | 2 +- go.sum | 2 + log/log_test.go | 24 ++++ server/errors.go | 3 +- server/server.go | 3 + server/server_account.go | 2 +- server/server_test.go | 15 ++- server/server_twilio_test.go | 8 +- server/types.go | 2 +- user/manager.go | 6 +- user/manager_test.go | 32 ++++++ web/package-lock.json | 202 ++++++++++++++++------------------ web/src/app/AccountApi.js | 10 +- web/src/components/Account.js | 17 +-- 16 files changed, 201 insertions(+), 133 deletions(-) diff --git a/docs/config.md b/docs/config.md index d6f6e408..df77e9a7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -856,8 +856,8 @@ billing-contact: "phil@example.com" ``` ## Phone calls -ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a phone call provider. If phone calls are enabled, -users can verify and add a phone number, and then receive phone calls when publish a message with the `X-Call` header. +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 diff --git a/docs/publish.md b/docs/publish.md index 3cca6fc6..1b5957b9 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2709,7 +2709,7 @@ You may also simply pass `yes` as a value to pick the first of your verified pho On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
- ![e-mail publishing](static/img/web-phone-verify.png) + ![phone number verification](static/img/web-phone-verify.png)
Phone number verification in the web app
diff --git a/go.mod b/go.mod index 1f4c9e75..162fd943 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( require ( cloud.google.com/go v0.110.2 // indirect - cloud.google.com/go/compute v1.19.2 // indirect + cloud.google.com/go/compute v1.19.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v1.0.1 // indirect cloud.google.com/go/longrunning v0.4.2 // indirect diff --git a/go.sum b/go.sum index ff2d580f..73cd9d8f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= +cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= diff --git a/log/log_test.go b/log/log_test.go index ed35b495..d7ceb1c9 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -198,6 +198,30 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) { require.Equal(t, "", File()) } +func TestLog_FieldIf(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetLevel(DebugLevel) + SetFormat(JSONFormat) + + Time(time.Unix(11, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // This is not logged + Field("tag", "manager"). + Debug("trace_field is not logged") + SetLevel(TraceLevel) + Time(time.Unix(12, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // Now it is logged + Field("tag", "manager"). + Debug("trace_field is logged") + + expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"} +{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"} +` + require.Equal(t, expected, out.String()) +} + func TestLog_UsingStdLogger_JSON(t *testing.T) { t.Cleanup(resetState) diff --git a/server/errors.go b/server/errors.go index b67558db..eee916b5 100644 --- a/server/errors.go +++ b/server/errors.go @@ -108,11 +108,12 @@ var ( errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil} errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index fb448015..20b8ce03 100644 --- a/server/server.go +++ b/server/server.go @@ -937,6 +937,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if email != "" { return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } + if call != "" { + return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { return false, false, "", "", false, errHTTPBadRequestDelayCannotParse diff --git a/server/server_account.go b/server/server_account.go index b9997ef3..a0bbaeaf 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -581,7 +581,7 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R return errHTTPBadRequestPhoneNumberInvalid } logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number") - if err := s.userManager.DeletePhoneNumber(u.ID, req.Number); err != nil { + if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/server/server_test.go b/server/server_test.go index adf77a73..e231ab73 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1190,7 +1190,20 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { "E-Mail": "test@example.com", "Delay": "20 min", }) - require.Equal(t, 400, response.Code) + require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_PublishDelayedCall_Fail(t *testing.T) { + c := newTestConfig(t) + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "Call": "yes", + "Delay": "20 min", + }) + require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 5b320959..642ad756 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -43,7 +43,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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)) + 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() @@ -69,7 +69,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { require.Nil(t, err) // Send verification code for phone number - response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444"}`, map[string]string{ + 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) @@ -122,7 +122,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) { 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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)) + 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() @@ -167,7 +167,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { 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+notification+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+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%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)) + 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() diff --git a/server/types.go b/server/types.go index 3b733678..1e9457be 100644 --- a/server/types.go +++ b/server/types.go @@ -318,7 +318,7 @@ type apiAccountPhoneNumberVerifyRequest struct { type apiAccountPhoneNumberAddRequest struct { Number string `json:"number"` - Code string `json:"code,omitempty"` + Code string `json:"code"` // Only set when adding a phone number } type apiAccountTier struct { diff --git a/user/manager.go b/user/manager.go index c57ede58..00407ab3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -117,7 +117,6 @@ const ( PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -420,7 +419,6 @@ const ( PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); ` ) @@ -694,8 +692,8 @@ func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { return nil } -// DeletePhoneNumber deletes a phone number from the user with the given user ID -func (a *Manager) DeletePhoneNumber(userID string, phoneNumber string) error { +// 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 } diff --git a/user/manager_test.go b/user/manager_test.go index cd2e1032..de1ad6fb 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -893,6 +893,38 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) { require.Nil(t, a.ResetTier("phil")) } +func TestUser_PhoneNumberAddListRemove(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + + phoneNumbers, err := a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+1234567890", phoneNumbers[0]) + + require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) + phoneNumbers, err = a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) +} + +func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) +} + func TestSqliteCache_Migration_From1(t *testing.T) { filename := filepath.Join(t.TempDir(), "user.db") db, err := sql.Open("sqlite3", filename) diff --git a/web/package-lock.json b/web/package-lock.json index f1b4785f..5457f17d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3134,14 +3134,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-beta.0", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz", - "integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==", + "version": "5.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.1.tgz", + "integrity": "sha512-xrkDCeu3JQE+JjJUnJnOrdQJMXwKhbV4AW+FRjMIj5i9cHK3BAuatG/iqbf1M+jklVWLk0KdbgioKwK+03aYbA==", "dependencies": { "@babel/runtime": "^7.21.0", "@emotion/is-prop-valid": "^1.2.0", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@popperjs/core": "^2.11.7", "clsx": "^1.2.1", "prop-types": "^15.8.1", @@ -3166,9 +3166,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz", - "integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.1.tgz", + "integrity": "sha512-qDHtNDO72NcBQMhaWBt9EZMvNiO+OXjPg5Sdk/6LgRDw6Zr3HdEZ5n2FJ/qtYsaT/okGyCuQavQkcZCOCEVf/g==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui" @@ -3200,16 +3200,16 @@ } }, "node_modules/@mui/material": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz", - "integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz", + "integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/base": "5.0.0-beta.0", - "@mui/core-downloads-tracker": "^5.13.0", - "@mui/system": "^5.12.3", + "@mui/base": "5.0.0-beta.1", + "@mui/core-downloads-tracker": "^5.13.1", + "@mui/system": "^5.13.1", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@types/react-transition-group": "^4.4.6", "clsx": "^1.2.1", "csstype": "^3.1.2", @@ -3244,12 +3244,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz", - "integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz", + "integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "prop-types": "^15.8.1" }, "engines": { @@ -3301,15 +3301,15 @@ } }, "node_modules/@mui/system": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz", - "integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz", + "integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/private-theming": "^5.12.3", + "@mui/private-theming": "^5.13.1", "@mui/styled-engine": "^5.12.3", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "clsx": "^1.2.1", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -3353,13 +3353,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", - "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz", + "integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==", "dependencies": { "@babel/runtime": "^7.21.0", "@types/prop-types": "^15.7.5", - "@types/react-is": "^16.7.1 || ^17.0.0", + "@types/react-is": "^18.2.0", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -4016,9 +4016,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", - "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" + "version": "20.1.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz", + "integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4061,21 +4061,11 @@ } }, "node_modules/@types/react-is": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz", - "integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==", "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@types/react-is/node_modules/@types/react": { - "version": "17.0.59", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.59.tgz", - "integrity": "sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" + "@types/react": "*" } }, "node_modules/@types/react-transition-group": { @@ -4175,14 +4165,14 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz", - "integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", + "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/type-utils": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/type-utils": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -4208,11 +4198,11 @@ } }, "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz", - "integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz", + "integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==", "dependencies": { - "@typescript-eslint/utils": "5.59.5" + "@typescript-eslint/utils": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4226,13 +4216,13 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz", - "integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", + "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", "dependencies": { - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "debug": "^4.3.4" }, "engines": { @@ -4252,12 +4242,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz", - "integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", + "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5" + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4268,12 +4258,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz", - "integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", + "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", "dependencies": { - "@typescript-eslint/typescript-estree": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/typescript-estree": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -4294,9 +4284,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz", - "integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", + "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4306,12 +4296,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz", - "integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", + "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -4332,16 +4322,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz", - "integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", + "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -4377,11 +4367,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz", - "integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", + "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", "dependencies": { - "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/types": "5.59.6", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -4956,9 +4946,9 @@ } }, "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", + "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", "engines": { "node": ">=4" } @@ -5511,9 +5501,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001487", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", - "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", "funding": [ { "type": "opencollective", @@ -6749,9 +6739,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.394", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", - "integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==" + "version": "1.4.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz", + "integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q==" }, "node_modules/emittery": { "version": "0.8.1", @@ -9146,9 +9136,9 @@ } }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", + "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", "dependencies": { "has": "^1.0.3" }, @@ -13879,9 +13869,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz", - "integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15976,9 +15966,9 @@ } }, "node_modules/terser": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", - "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "version": "5.17.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz", + "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==", "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 8908f306..915e3bb8 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,14 +1,16 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, + accountPasswordUrl, + accountPhoneUrl, + accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, - accountSubscriptionSingleUrl, accountSubscriptionUrl, accountTokenUrl, - accountUrl, maybeWithBearerAuth, + accountUrl, + maybeWithBearerAuth, tiersUrl, withBasicAuth, withBearerAuth @@ -18,7 +20,7 @@ import subscriptionManager from "./SubscriptionManager"; import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; -import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; +import {fetchOrThrow, UnauthorizedError} from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b480ea6b..83ef0b7d 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,13 +1,17 @@ import * as React from 'react'; import {useContext, useState} from 'react'; import { - Alert, ButtonGroup, + Alert, CardActions, - CardContent, Chip, - FormControl, FormControlLabel, InputLabel, + CardContent, + Chip, + FormControl, + FormControlLabel, LinearProgress, Link, - Portal, Radio, RadioGroup, + Portal, + Radio, + RadioGroup, Select, Snackbar, Stack, @@ -47,14 +51,12 @@ import {AccountContext} from "./App"; import DialogFooter from "./DialogFooter"; import {Paragraph} from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import {Check, ContentCopy, DeleteForever, Public} from "@mui/icons-material"; +import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {ProChip} from "./SubscriptionPopup"; import AddIcon from "@mui/icons-material/Add"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; const Account = () => { if (!session.exists()) { @@ -427,6 +429,7 @@ const AddPhoneNumberDialog = (props) => { const handleCancel = () => { if (verificationCodeSent) { setVerificationCodeSent(false); + setCode(""); } else { props.onClose(); } From 92c384374a5a74a382302a6fbb3a0dc6dcee61c8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 10:58:28 -0400 Subject: [PATCH 19/20] More self-review --- go.sum | 2 -- server/server.go | 3 ++- server/server.yml | 5 +++++ server/server_account.go | 38 +++++++++++++++++++---------------- server/server_account_test.go | 6 ++++++ server/server_test.go | 2 +- server/server_twilio_test.go | 4 ++-- server/types.go | 1 + web/package-lock.json | 8 ++++---- web/public/config.js | 3 ++- web/src/components/Account.js | 34 ++++++++++++++++--------------- 11 files changed, 62 insertions(+), 44 deletions(-) diff --git a/go.sum b/go.sum index 73cd9d8f..bfaf339d 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= -cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= -cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= diff --git a/server/server.go b/server/server.go index 20b8ce03..7e8ea251 100644 --- a/server/server.go +++ b/server/server.go @@ -550,6 +550,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", EnableCalls: s.config.TwilioAccount != "", + EnableEmails: s.config.SMTPSenderFrom != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, @@ -911,7 +912,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") - if call != "" && s.config.TwilioAccount == "" && s.userManager == nil { + 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 diff --git a/server/server.yml b/server/server.yml index 6728d6a4..74841137 100644 --- a/server/server.yml +++ b/server/server.yml @@ -146,6 +146,11 @@ # 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: diff --git a/server/server_account.go b/server/server_account.go index a0bbaeaf..6e6a6864 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -108,17 +108,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), } } - reservations, err := s.userManager.Reservations(u.Name) - if err != nil { - return err - } - if len(reservations) > 0 { - response.Reservations = make([]*apiAccountReservation, 0) - for _, r := range reservations { - response.Reservations = append(response.Reservations, &apiAccountReservation{ - Topic: r.Topic, - Everyone: r.Everyone.String(), - }) + if s.config.EnableReservations { + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.Topic, + Everyone: r.Everyone.String(), + }) + } } } tokens, err := s.userManager.Tokens(u.ID) @@ -141,12 +143,14 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis }) } } - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } - if len(phoneNumbers) > 0 { - response.PhoneNumbers = phoneNumbers + if s.config.TwilioAccount != "" { + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + if len(phoneNumbers) > 0 { + response.PhoneNumbers = phoneNumbers + } } } else { response.Username = user.Everyone diff --git a/server/server_account_test.go b/server/server_account_test.go index 465e4be1..119efb16 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,6 +151,8 @@ func TestAccount_Get_Anonymous(t *testing.T) { require.Equal(t, int64(1004), account.Stats.MessagesRemaining) require.Equal(t, int64(0), account.Stats.Emails) require.Equal(t, int64(24), account.Stats.EmailsRemaining) + require.Equal(t, int64(0), account.Stats.Calls) + require.Equal(t, int64(0), account.Stats.CallsRemaining) rr = request(t, s, "POST", "/mytopic", "", nil) require.Equal(t, 200, rr.Code) @@ -498,6 +500,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.EnableSignup = true + conf.EnableReservations = true + conf.TwilioAccount = "dummy" s := newTestServer(t, conf) // Create user @@ -510,6 +514,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { MessageLimit: 123, MessageExpiryDuration: 86400 * time.Second, EmailLimit: 32, + CallLimit: 10, ReservationLimit: 2, AttachmentFileSizeLimit: 1231231, AttachmentTotalSizeLimit: 123123, @@ -551,6 +556,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { require.Equal(t, int64(123), account.Limits.Messages) require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) require.Equal(t, int64(32), account.Limits.Emails) + require.Equal(t, int64(10), account.Limits.Calls) require.Equal(t, int64(2), account.Limits.Reservations) require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) diff --git a/server/server_test.go b/server/server_test.go index e231ab73..57251413 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1194,7 +1194,7 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { } func TestServer_PublishDelayedCall_Fail(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 642ad756..1b710130 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -228,7 +228,7 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -242,7 +242,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { } func TestServer_Twilio_Call_Anonymous(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" diff --git a/server/types.go b/server/types.go index 1e9457be..4280f6c9 100644 --- a/server/types.go +++ b/server/types.go @@ -394,6 +394,7 @@ type apiConfigResponse struct { EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` EnableCalls bool `json:"enable_calls"` + EnableEmails bool `json:"enable_emails"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/web/package-lock.json b/web/package-lock.json index 5457f17d..0d2670ff 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16262,16 +16262,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/web/public/config.js b/web/public/config.js index b49e440b..89bbed9f 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,12 +6,13 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server + base_url: window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, enable_payments: true, enable_reservations: true, + enable_emails: true, enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 83ef0b7d..710510d2 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -571,22 +571,24 @@ const Stats = () => { value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100} /> - - {t("account_usage_emails_title")} - - - }> -
- {account.stats.emails.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} -
- -
- {(account.role === Role.ADMIN || account.limits.calls > 0) && + {config.enable_emails && + + {t("account_usage_emails_title")} + + + }> +
+ {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} +
+ +
+ } + {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && {t("account_usage_calls_title")} From fc1087a42b797c75835e15d6366998471d78c281 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 11:19:48 -0400 Subject: [PATCH 20/20] The last one --- server/server_middleware.go | 2 +- server/server_twilio.go | 2 ++ user/manager_test.go | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/server_middleware.go b/server/server_middleware.go index 0e4aff7c..7aea45a3 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -87,7 +87,7 @@ 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 == "" { + if s.config.TwilioAccount == "" || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_twilio.go b/server/server_twilio.go index 11f58aa7..06723574 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -58,6 +58,8 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, * 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 { diff --git a/user/manager_test.go b/user/manager_test.go index de1ad6fb..5e01f497 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -910,6 +910,12 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) { 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) {