From 1c0162c43457e7e7a11c215b014aa537af67bbc4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 5 May 2023 16:22:54 -0400 Subject: [PATCH] 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)) +}