This commit is contained in:
binwiederhier 2023-05-11 13:50:10 -04:00
parent a26a6be62b
commit d4767caf30
10 changed files with 279 additions and 26 deletions

View file

@ -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-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-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 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: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
@ -159,6 +160,7 @@ func execServe(c *cli.Context) error {
twilioAccount := c.String("twilio-account") twilioAccount := c.String("twilio-account")
twilioAuthToken := c.String("twilio-auth-token") twilioAuthToken := c.String("twilio-auth-token")
twilioFromNumber := c.String("twilio-from-number") twilioFromNumber := c.String("twilio-from-number")
twilioVerifyService := c.String("twilio-verify-service")
totalTopicLimit := c.Int("global-topic-limit") totalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
@ -323,6 +325,7 @@ func execServe(c *cli.Context) error {
conf.TwilioAccount = twilioAccount conf.TwilioAccount = twilioAccount
conf.TwilioAuthToken = twilioAuthToken conf.TwilioAuthToken = twilioAuthToken
conf.TwilioFromNumber = twilioFromNumber conf.TwilioFromNumber = twilioFromNumber
conf.TwilioVerifyService = twilioVerifyService
conf.TotalTopicLimit = totalTopicLimit conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit

View file

@ -107,10 +107,12 @@ type Config struct {
SMTPServerListen string SMTPServerListen string
SMTPServerDomain string SMTPServerDomain string
SMTPServerAddrPrefix string SMTPServerAddrPrefix string
TwilioBaseURL string TwilioMessagingBaseURL string
TwilioAccount string TwilioAccount string
TwilioAuthToken string TwilioAuthToken string
TwilioFromNumber string TwilioFromNumber string
TwilioVerifyBaseURL string
TwilioVerifyService string
MetricsEnable bool MetricsEnable bool
MetricsListenHTTP string MetricsListenHTTP string
ProfileListenHTTP string ProfileListenHTTP string
@ -191,10 +193,12 @@ func NewConfig() *Config {
SMTPServerListen: "", SMTPServerListen: "",
SMTPServerDomain: "", SMTPServerDomain: "",
SMTPServerAddrPrefix: "", SMTPServerAddrPrefix: "",
TwilioBaseURL: "https://api.twilio.com", // Override for tests TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests
TwilioAccount: "", TwilioAccount: "",
TwilioAuthToken: "", TwilioAuthToken: "",
TwilioFromNumber: "", TwilioFromNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
MessageLimit: DefaultMessageLengthLimit, MessageLimit: DefaultMessageLengthLimit,
MinDelay: DefaultMinDelay, MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay, MaxDelay: DefaultMaxDelay,

View file

@ -88,6 +88,7 @@ var (
apiAccountSettingsPath = "/v1/account/settings" apiAccountSettingsPath = "/v1/account/settings"
apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountSubscriptionPath = "/v1/account/subscription"
apiAccountReservationPath = "/v1/account/reservation" apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone"
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"
@ -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) 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 == 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 { } 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 {

View file

@ -149,6 +149,7 @@
# twilio-account: # twilio-account:
# twilio-auth-token: # twilio-auth-token:
# twilio-from-number: # twilio-from-number:
# twilio-verify-service:
# Interval in which keepalive messages are sent to the client. This is to prevent # Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity. # intermediaries closing the connection for inactivity.

View file

@ -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 { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
@ -517,6 +530,80 @@ 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 {
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 // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) { func (s *Server) publishSyncEventAsync(v *visitor) {
go func() { go func() {

View file

@ -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("From", s.config.TwilioFromNumber)
data.Set("To", to) data.Set("To", to)
data.Set("Body", body) 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) { 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("From", s.config.TwilioFromNumber)
data.Set("To", to) data.Set("To", to)
data.Set("Twiml", body) 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{ logContext := log.Context{
"twilio_from": s.config.TwilioFromNumber, "twilio_from": s.config.TwilioFromNumber,
"twilio_to": to, "twilio_to": to,
@ -61,7 +123,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m
} else if ev.IsDebug() { } else if ev.IsDebug() {
ev.Debug("Sending Twilio request") ev.Debug("Sending Twilio request")
} }
response, err := s.performTwilioRequestInternal(endpoint, data) response, err := s.performTwilioMessagingRequestInternal(endpoint, data)
if err != nil { if err != nil {
ev. ev.
Field("twilio_body", body). Field("twilio_body", body).
@ -79,8 +141,8 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m
minc(msuccess) minc(msuccess)
} }
func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint) 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())) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil { if err != nil {
return "", err return "", err

View file

@ -25,7 +25,7 @@ func TestServer_Twilio_SMS(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.BaseURL = "https://ntfy.sh" c.BaseURL = "https://ntfy.sh"
c.TwilioBaseURL = twilioServer.URL c.TwilioMessagingBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -58,7 +58,7 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) {
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.BaseURL = "https://ntfy.sh" c.BaseURL = "https://ntfy.sh"
c.TwilioBaseURL = twilioServer.URL c.TwilioMessagingBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -104,7 +104,7 @@ func TestServer_Twilio_Call(t *testing.T) {
defer twilioServer.Close() defer twilioServer.Close()
c := newTestConfig(t) c := newTestConfig(t)
c.TwilioBaseURL = twilioServer.URL c.TwilioMessagingBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -139,7 +139,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
defer twilioServer.Close() defer twilioServer.Close()
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.TwilioBaseURL = twilioServer.URL c.TwilioMessagingBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -167,7 +167,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.TwilioBaseURL = "https://127.0.0.1" c.TwilioMessagingBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
@ -181,7 +181,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.TwilioBaseURL = "https://127.0.0.1" c.TwilioMessagingBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"

View file

@ -277,6 +277,16 @@ type apiAccountTokenResponse struct {
Expires int64 `json:"expires,omitempty"` // Unix timestamp 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 { type apiAccountTier struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
@ -334,6 +344,7 @@ type apiAccountResponse struct {
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
@ -419,3 +430,7 @@ type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"` ID string `json:"id"`
Customer string `json:"customer"` Customer string `json:"customer"`
} }
type apiTwilioVerifyResponse struct {
Status string `json:"status"`
}

View file

@ -113,6 +113,14 @@ const (
PRIMARY KEY (user_id, token), PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
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 ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
version INT NOT NULL 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 = ` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -402,6 +414,14 @@ const (
ALTER TABLE tier ADD COLUMN calls_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_sms INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_calls 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 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 // RemoveDeletedUsers deletes all users that have been marked deleted for
func (a *Manager) RemoveDeletedUsers() error { func (a *Manager) RemoveDeletedUsers() error {
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {

View file

@ -71,6 +71,11 @@ type TokenUpdate struct {
LastOrigin netip.Addr LastOrigin netip.Addr
} }
type PhoneNumber struct {
Number string
Verified bool
}
// Prefs represents a user's configuration settings // Prefs represents a user's configuration settings
type Prefs struct { type Prefs struct {
Language *string `json:"language,omitempty"` Language *string `json:"language,omitempty"`
@ -282,5 +287,6 @@ var (
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrTierNotFound = errors.New("tier not found") ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found") ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrTooManyReservations = errors.New("new tier has lower reservation limit")
) )