diff --git a/server/server.go b/server/server.go index 7bc096f..1a9309c 100644 --- a/server/server.go +++ b/server/server.go @@ -98,7 +98,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) - phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site webFs embed.FS diff --git a/server/server_metrics.go b/server/server_metrics.go index d3f1792..d2e6f1c 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,6 +15,10 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter + metricSMSSentSuccess prometheus.Counter + metricSMSSentFailure prometheus.Counter + metricCallsMadeSuccess prometheus.Counter + metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter metricMatrixPublishedSuccess prometheus.Counter metricMatrixPublishedFailure prometheus.Counter @@ -57,6 +61,18 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_emails_received_failure", }) + metricSMSSentSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_success", + }) + metricSMSSentFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_failure", + }) + metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_success", + }) + metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_failure", + }) metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_unifiedpush_published_success", }) @@ -95,6 +111,10 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, + metricSMSSentSuccess, + metricSMSSentFailure, + metricCallsMadeSuccess, + metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, metricMatrixPublishedSuccess, metricMatrixPublishedFailure, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc21aac..fc5fb65 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/xml" "fmt" + "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/util" "io" @@ -36,7 +37,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Body", body) - s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { @@ -45,10 +46,10 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) } -func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) { +func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { logContext := log.Context{ "twilio_from": s.config.TwilioFromNumber, "twilio_to": to, @@ -66,6 +67,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e Field("twilio_response", response). Err(err). Warn("Error sending Twilio request") + minc(mfailure) return } if ev.IsTrace() { @@ -73,6 +75,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e } else if ev.IsDebug() { ev.Debug("Received successful Twilio response") } + minc(msuccess) } func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 16c1274..d99f9b6 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,37 +2,113 @@ package server import ( "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" ) func TestServer_Twilio_SMS(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+9.9.9.9+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + c := newTestConfig(t) - c.TwilioBaseURL = "http://" - c.TwilioAccount = "AC123" - c.TwilioAuthToken = "secret-token" - c.TwilioFromNumber = "+123456789" + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "SMS": "+11122233344", }) - require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) - require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) - require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) - require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) - require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ethis+message+has%26%23xA%3Ba+new+line+and+%26lt%3Bbrackets%26gt%3B%21%26%23xA%3Band+%26%2334%3Bquotes+and+other+%26%2339%3Bquotes%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+9.9.9.9+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfig(t) + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + body := `this message has +a new line and ! +and "quotes and other 'quotes` + response := request(t, s, "POST", "/mytopic", body, map[string]string{ + "x-call": "+11122233344", + }) + require.Equal(t, "this message has\na new line and !\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+invalid", + }) + require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-sms": "+invalid", + }) + require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-sms": "+1234", + }) + require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_Call_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+1234", + }) + require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) }