Cont'd Twilio stuff

This commit is contained in:
binwiederhier 2023-05-16 14:15:58 -04:00
parent deb4f24856
commit 7c574d73de
16 changed files with 240 additions and 236 deletions

View file

@ -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: "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-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-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.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
@ -217,8 +217,8 @@ func execServe(c *cli.Context) error {
return errors.New("cannot set enable-signup without also setting enable-login") return errors.New("cannot set enable-signup without also setting enable-login")
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") { } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number and base-url must also be set") return errors.New("if twilio-account is set, twilio-auth-token, twilio-from-number, twilio-verify-service, base-url, and auth-file must also be set")
} }
// Backwards compatibility // Backwards compatibility

View file

@ -18,7 +18,7 @@ const (
defaultMessageLimit = 5000 defaultMessageLimit = 5000
defaultMessageExpiryDuration = "12h" defaultMessageExpiryDuration = "12h"
defaultEmailLimit = 20 defaultEmailLimit = 20
defaultCallLimit = 10 defaultCallLimit = 0
defaultReservationLimit = 3 defaultReservationLimit = 3
defaultAttachmentFileSizeLimit = "15M" defaultAttachmentFileSizeLimit = "15M"
defaultAttachmentTotalSizeLimit = "100M" defaultAttachmentTotalSizeLimit = "100M"

View file

@ -41,34 +41,34 @@ func newEvent() *Event {
// Fatal logs the event as FATAL, and exits the program with exit code 1 // Fatal logs the event as FATAL, and exits the program with exit code 1
func (e *Event) Fatal(message string, v ...any) { func (e *Event) Fatal(message string, v ...any) {
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...) e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
os.Exit(1) os.Exit(1)
} }
// Error logs the event with log level error // Error logs the event with log level error
func (e *Event) Error(message string, v ...any) { func (e *Event) Error(message string, v ...any) *Event {
e.maybeLog(ErrorLevel, message, v...) return e.Log(ErrorLevel, message, v...)
} }
// Warn logs the event with log level warn // Warn logs the event with log level warn
func (e *Event) Warn(message string, v ...any) { func (e *Event) Warn(message string, v ...any) *Event {
e.maybeLog(WarnLevel, message, v...) return e.Log(WarnLevel, message, v...)
} }
// Info logs the event with log level info // Info logs the event with log level info
func (e *Event) Info(message string, v ...any) { func (e *Event) Info(message string, v ...any) *Event {
e.maybeLog(InfoLevel, message, v...) return e.Log(InfoLevel, message, v...)
} }
// Debug logs the event with log level debug // Debug logs the event with log level debug
func (e *Event) Debug(message string, v ...any) { func (e *Event) Debug(message string, v ...any) *Event {
e.maybeLog(DebugLevel, message, v...) return e.Log(DebugLevel, message, v...)
} }
// Trace logs the event with log level trace // Trace logs the event with log level trace
func (e *Event) Trace(message string, v ...any) { func (e *Event) Trace(message string, v ...any) *Event {
e.maybeLog(TraceLevel, message, v...) return e.Log(TraceLevel, message, v...)
} }
// Tag adds a "tag" field to the log event // Tag adds a "tag" field to the log event
@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event {
return e return e
} }
// FieldIf adds a custom field and value to the log event if the given level is loggable
func (e *Event) FieldIf(key string, value any, level Level) *Event {
if e.Loggable(level) {
return e.Field(key, value)
}
return e
}
// Fields adds a map of fields to the log event // Fields adds a map of fields to the log event
func (e *Event) Fields(fields Context) *Event { func (e *Event) Fields(fields Context) *Event {
if e.fields == nil { if e.fields == nil {
@ -138,7 +146,7 @@ func (e *Event) With(contexters ...Contexter) *Event {
// to determine if they match. This is super complicated, but required for efficiency. // to determine if they match. This is super complicated, but required for efficiency.
func (e *Event) Render(l Level, message string, v ...any) string { func (e *Event) Render(l Level, message string, v ...any) string {
appliedContexters := e.maybeApplyContexters() appliedContexters := e.maybeApplyContexters()
if !e.shouldLog(l) { if !e.Loggable(l) {
return "" return ""
} }
e.Message = fmt.Sprintf(message, v...) e.Message = fmt.Sprintf(message, v...)
@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string {
return e.String() return e.String()
} }
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string // Log logs the event to the defined output, or does nothing if Render returns an empty string
func (e *Event) maybeLog(l Level, message string, v ...any) { func (e *Event) Log(l Level, message string, v ...any) *Event {
if m := e.Render(l, message, v...); m != "" { if m := e.Render(l, message, v...); m != "" {
log.Println(m) log.Println(m)
} }
return e
} }
// Loggable returns true if the given log level is lower or equal to the current log level // Loggable returns true if the given log level is lower or equal to the current log level
@ -199,10 +208,6 @@ func (e *Event) String() string {
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
} }
func (e *Event) shouldLog(l Level) bool {
return e.globalLevelWithOverride() <= l
}
func (e *Event) globalLevelWithOverride() Level { func (e *Event) globalLevelWithOverride() Level {
mu.RLock() mu.RLock()
l, ov := level, overrides l, ov := level, overrides

View file

@ -47,7 +47,6 @@ const (
DefaultVisitorMessageDailyLimit = 0 DefaultVisitorMessageDailyLimit = 0
DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorCallDailyLimit = 10
DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
DefaultVisitorAuthFailureLimitBurst = 30 DefaultVisitorAuthFailureLimitBurst = 30
@ -106,10 +105,10 @@ type Config struct {
SMTPServerListen string SMTPServerListen string
SMTPServerDomain string SMTPServerDomain string
SMTPServerAddrPrefix string SMTPServerAddrPrefix string
TwilioMessagingBaseURL string
TwilioAccount string TwilioAccount string
TwilioAuthToken string TwilioAuthToken string
TwilioFromNumber string TwilioFromNumber string
TwilioCallsBaseURL string
TwilioVerifyBaseURL string TwilioVerifyBaseURL string
TwilioVerifyService string TwilioVerifyService string
MetricsEnable bool MetricsEnable bool
@ -190,7 +189,7 @@ func NewConfig() *Config {
SMTPServerListen: "", SMTPServerListen: "",
SMTPServerDomain: "", SMTPServerDomain: "",
SMTPServerAddrPrefix: "", SMTPServerAddrPrefix: "",
TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
TwilioAccount: "", TwilioAccount: "",
TwilioAuthToken: "", TwilioAuthToken: "",
TwilioFromNumber: "", TwilioFromNumber: "",

View file

@ -91,6 +91,7 @@ var (
apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountSubscriptionPath = "/v1/account/subscription"
apiAccountReservationPath = "/v1/account/reservation" apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone" apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@ -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) return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) return s.ensureUser(s.ensureCallsEnabled(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 { } 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 { } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
return s.handleStats(w, r, v) return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@ -910,7 +911,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
return false, false, "", "", false, errHTTPBadRequestEmailDisabled return false, false, "", "", false, errHTTPBadRequestEmailDisabled
} }
call = readParam(r, "x-call", "call") call = readParam(r, "x-call", "call")
if call != "" && s.config.TwilioAccount == "" { if call != "" && s.config.TwilioAccount == "" && s.userManager == nil {
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid

View file

@ -144,7 +144,7 @@
# smtp-server-domain: # smtp-server-domain:
# smtp-server-addr-prefix: # 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-account:
# twilio-auth-token: # twilio-auth-token:
@ -225,17 +225,11 @@
# visitor-request-limit-exempt-hosts: "" # visitor-request-limit-exempt-hosts: ""
# Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset # 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) # every day at midnight UTC. If the limit is not set (or set to zero), the request
# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. # limit (see above) governs the upper limit.
# #
# visitor-message-daily-limit: 0 # 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: # Rate limiting: Allowed emails per visitor:
# - visitor-email-limit-burst is the initial bucket of emails each visitor has # - 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 # - visitor-email-limit-replenish is the rate at which the bucket is refilled

View file

@ -521,7 +521,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
return nil 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() u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { 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 // Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number 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 err
} }
return s.writeJSON(w, newSuccessResponse()) 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() u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
@ -560,7 +560,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
if !phoneNumberRegex.MatchString(req.Number) { if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid 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 return err
} }
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")

View file

@ -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 { func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil { if s.config.StripeSecretKey == "" || s.stripe == nil {

View file

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"github.com/prometheus/client_golang/prometheus"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
@ -15,24 +14,26 @@ import (
) )
const ( const (
twilioCallEndpoint = "Calls.json" twilioCallFormat = `
twilioCallFormat = `
<Response> <Response>
<Pause length="1"/> <Pause length="1"/>
<Say loop="5"> <Say loop="3">
You have a notification from notify on topic %s. Message: You have a notification from notify on topic %s. Message:
<break time="1s"/> <break time="1s"/>
%s %s
<break time="1s"/> <break time="1s"/>
End message. End message.
<break time="1s"/> <break time="1s"/>
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.
<break time="3s"/> <break time="3s"/>
</Say> </Say>
<Say>Goodbye.</Say> <Say>Goodbye.</Say>
</Response>` </Response>`
) )
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error.
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil { if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed 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("From", s.config.TwilioFromNumber)
data.Set("To", to) data.Set("To", to)
data.Set("Twiml", body) 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 { func (s *Server) callPhoneInternal(data url.Values) (string, error) {
logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") 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 := url.Values{}
data.Set("To", phoneNumber) data.Set("To", phoneNumber)
data.Set("Channel", "sms") data.Set("Channel", "sms")
@ -86,21 +114,16 @@ func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) er
return err return err
} }
response, err := io.ReadAll(resp.Body) response, err := io.ReadAll(resp.Body)
ev := logvr(v, r).Tag(tagTwilio)
if err != nil { if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request") ev.Err(err).Warn("Error sending Twilio phone verification request")
return err return err
} }
if ev.IsTrace() { ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
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 return nil
} }
func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error { func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{} data := url.Values{}
data.Set("To", phoneNumber) data.Set("To", phoneNumber)
data.Set("Code", code) 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.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 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) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return err return err
@ -144,54 +163,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code
return nil 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 { func xmlEscapeText(text string) string {
var buf bytes.Buffer var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text)) _ = xml.EscapeText(&buf, []byte(text))

View file

@ -11,37 +11,108 @@ import (
"testing" "testing"
) )
func TestServer_Twilio_SMS(t *testing.T) { func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
var called atomic.Bool var called, verified atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var code atomic.Pointer[string]
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
require.Nil(t, err) 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, "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) called.Store(true)
})) }))
defer twilioServer.Close() defer twilioCallsServer.Close()
c := newTestConfig(t) c := newTestConfigWithAuthFile(t)
c.BaseURL = "https://ntfy.sh" c.TwilioVerifyBaseURL = twilioVerifyServer.URL
c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioCallsBaseURL = twilioCallsServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
c.VisitorSMSDailyLimit = 1 c.TwilioVerifyService = "VA1234567890"
s := newTestServer(t, c) s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{ // Add tier and user
"SMS": "+11122233344", 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 { waitFor(t, func() bool {
return called.Load() 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 var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() { if called.Load() {
@ -49,16 +120,15 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) {
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
require.Nil(t, err) 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, "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) called.Store(true)
})) }))
defer twilioServer.Close() defer twilioServer.Close()
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.BaseURL = "https://ntfy.sh" c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioMessagingBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -68,62 +138,26 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) {
require.Nil(t, s.userManager.AddTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro", Code: "pro",
MessageLimit: 10, MessageLimit: 10,
SMSLimit: 1, CallLimit: 1,
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) 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 // Do the thing
response := request(t, s, "POST", "/mytopic", "test", map[string]string{ response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "authorization": util.BasicAuth("phil", "phil"),
"SMS": "+11122233344", "x-call": "+11122233344",
}) })
require.Equal(t, "test", toMessage(t, response.Body.String()).Message) require.Equal(t, "hi there", 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 <brackets>!
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 <brackets>!\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool { waitFor(t, func() bool {
return called.Load() 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 var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() { if called.Load() {
@ -133,13 +167,44 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.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, "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) called.Store(true)
})) }))
defer twilioServer.Close() defer twilioServer.Close()
c := newTestConfigWithAuthFile(t) 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.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -155,19 +220,16 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do the thing // 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"), "authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344", "x-call": "+11122233344",
}) })
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
waitFor(t, func() bool {
return called.Load()
})
} }
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" 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{ response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+invalid", "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 := newTestConfig(t)
c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
s := newTestServer(t, c) s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{ 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) require.Equal(t, 40035, 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) { 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{ response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+1234", "x-call": "+1234",
}) })
require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
} }

View file

@ -326,7 +326,6 @@ type apiAccountLimits struct {
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
SMS int64 `json:"sms"`
Calls int64 `json:"calls"` Calls int64 `json:"calls"`
Reservations int64 `json:"reservations"` Reservations int64 `json:"reservations"`
AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentTotalSize int64 `json:"attachment_total_size"`
@ -340,8 +339,6 @@ type apiAccountStats struct {
MessagesRemaining int64 `json:"messages_remaining"` MessagesRemaining int64 `json:"messages_remaining"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
EmailsRemaining int64 `json:"emails_remaining"` EmailsRemaining int64 `json:"emails_remaining"`
SMS int64 `json:"sms"`
SMSRemaining int64 `json:"sms_remaining"`
Calls int64 `json:"calls"` Calls int64 `json:"calls"`
CallsRemaining int64 `json:"calls_remaining"` CallsRemaining int64 `json:"calls_remaining"`
Reservations int64 `json:"reservations"` Reservations int64 `json:"reservations"`

View file

@ -130,7 +130,7 @@
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
"publish_dialog_email_reset": "Remove email forward", "publish_dialog_email_reset": "Remove email forward",
"publish_dialog_call_label": "Phone call", "publish_dialog_call_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_call_reset": "Remove phone call",
"publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_label": "Attachment URL",
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",

View file

@ -1,7 +1,7 @@
import { import {
accountBillingPortalUrl, accountBillingPortalUrl,
accountBillingSubscriptionUrl, accountBillingSubscriptionUrl,
accountPasswordUrl, accountPhoneUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl,
accountReservationSingleUrl, accountReservationSingleUrl,
accountReservationUrl, accountReservationUrl,
accountSettingsUrl, accountSettingsUrl,
@ -299,8 +299,8 @@ class AccountApi {
return await response.json(); // May throw SyntaxError return await response.json(); // May throw SyntaxError
} }
async verifyPhone(phoneNumber) { async verifyPhoneNumber(phoneNumber) {
const url = accountPhoneUrl(config.base_url); const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`); console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PUT", method: "PUT",
@ -311,11 +311,11 @@ class AccountApi {
}); });
} }
async checkVerifyPhone(phoneNumber, code) { async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url); 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, { await fetchOrThrow(url, {
method: "POST", method: "PUT",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber, number: phoneNumber,

View file

@ -28,6 +28,7 @@ export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/ac
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];

View file

@ -432,7 +432,7 @@ const AddPhoneNumberDialog = (props) => {
const verifyPhone = async () => { const verifyPhone = async () => {
try { try {
setSending(true); setSending(true);
await accountApi.verifyPhone(phoneNumber); await accountApi.verifyPhoneNumber(phoneNumber);
setVerificationCodeSent(true); setVerificationCodeSent(true);
} catch (e) { } catch (e) {
console.log(`[Account] Error sending verification`, e); console.log(`[Account] Error sending verification`, e);
@ -449,7 +449,7 @@ const AddPhoneNumberDialog = (props) => {
const checkVerifyPhone = async () => { const checkVerifyPhone = async () => {
try { try {
setSending(true); setSending(true);
await accountApi.checkVerifyPhone(phoneNumber, code); await accountApi.addPhoneNumber(phoneNumber, code);
props.onClose(); props.onClose();
} catch (e) { } catch (e) {
console.log(`[Account] Error confirming verification`, e); console.log(`[Account] Error confirming verification`, e);

View file

@ -45,7 +45,6 @@ const PublishDialog = (props) => {
const [filename, setFilename] = useState(""); const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false); const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sms, setSms] = useState("");
const [call, setCall] = useState(""); const [call, setCall] = useState("");
const [delay, setDelay] = useState(""); const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false); const [publishAnother, setPublishAnother] = useState(false);
@ -54,7 +53,6 @@ const PublishDialog = (props) => {
const [showClickUrl, setShowClickUrl] = useState(false); const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
const [showSms, setShowSms] = useState(false);
const [showCall, setShowCall] = useState(false); const [showCall, setShowCall] = useState(false);
const [showDelay, setShowDelay] = useState(false); const [showDelay, setShowDelay] = useState(false);
@ -128,9 +126,6 @@ const PublishDialog = (props) => {
if (email.trim()) { if (email.trim()) {
url.searchParams.append("email", email.trim()); url.searchParams.append("email", email.trim());
} }
if (sms.trim()) {
url.searchParams.append("sms", sms.trim());
}
if (call.trim()) { if (call.trim()) {
url.searchParams.append("call", call.trim()); url.searchParams.append("call", call.trim());
} }
@ -416,27 +411,6 @@ const PublishDialog = (props) => {
/> />
</ClosableRow> </ClosableRow>
} }
{showSms &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_sms_reset")} onClose={() => {
setSms("");
setShowSms(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_sms_label")}
placeholder={t("publish_dialog_sms_placeholder")}
value={sms}
onChange={ev => setSms(ev.target.value)}
disabled={disabled}
type="tel"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_sms_label")
}}
/>
</ClosableRow>
}
{showCall && {showCall &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
setCall(""); setCall("");
@ -562,7 +536,6 @@ const PublishDialog = (props) => {
<div> <div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showSms && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_sms_label")} aria-label={t("publish_dialog_chip_sms_label")} onClick={() => setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}