binwiederhier 2 лет назад
Родитель
Сommit
1c0162c434
7 измененных файлов с 159 добавлено и 13 удалено
  1. 11 0
      cmd/serve.go
  2. 6 0
      server/config.go
  3. 2 0
      server/errors.go
  4. 1 0
      server/log.go
  5. 32 13
      server/server.go
  6. 6 0
      server/server.yml
  7. 101 0
      server/server_twilio.go

+ 11 - 0
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

+ 6 - 0
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,

+ 2 - 0
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}

+ 1 - 0
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"

+ 32 - 13
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.

+ 6 - 0
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.
 #

+ 101 - 0
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    = `
+<Response>
+	<Pause length="1"/>
+	<Say>You have a message from notify on topic %s. Message:</Say>
+	<Pause length="1"/>
+	<Say>%s</Say>
+	<Pause length="1"/>
+	<Say>End message.</Say>
+	<Pause length="1"/>
+	<Say>%s</Say>
+	<Pause length="1"/>
+</Response>`
+)
+
+func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
+	body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(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))
+}