diff --git a/cmd/serve.go b/cmd/serve.go index 28081a02..4e123e93 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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: "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-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.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"}), @@ -217,8 +217,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") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { + 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 diff --git a/cmd/tier.go b/cmd/tier.go index e77927c5..f1c8ddcb 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,7 +18,7 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 - defaultCallLimit = 10 + defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" diff --git a/log/event.go b/log/event.go index ccde4126..b4b8f59f 100644 --- a/log/event.go +++ b/log/event.go @@ -41,34 +41,34 @@ func newEvent() *Event { // Fatal logs the event as FATAL, and exits the program with exit code 1 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 os.Exit(1) } // Error logs the event with log level error -func (e *Event) Error(message string, v ...any) { - e.maybeLog(ErrorLevel, message, v...) +func (e *Event) Error(message string, v ...any) *Event { + return e.Log(ErrorLevel, message, v...) } // Warn logs the event with log level warn -func (e *Event) Warn(message string, v ...any) { - e.maybeLog(WarnLevel, message, v...) +func (e *Event) Warn(message string, v ...any) *Event { + return e.Log(WarnLevel, message, v...) } // Info logs the event with log level info -func (e *Event) Info(message string, v ...any) { - e.maybeLog(InfoLevel, message, v...) +func (e *Event) Info(message string, v ...any) *Event { + return e.Log(InfoLevel, message, v...) } // Debug logs the event with log level debug -func (e *Event) Debug(message string, v ...any) { - e.maybeLog(DebugLevel, message, v...) +func (e *Event) Debug(message string, v ...any) *Event { + return e.Log(DebugLevel, message, v...) } // Trace logs the event with log level trace -func (e *Event) Trace(message string, v ...any) { - e.maybeLog(TraceLevel, message, v...) +func (e *Event) Trace(message string, v ...any) *Event { + return e.Log(TraceLevel, message, v...) } // Tag adds a "tag" field to the log event @@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event { 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 func (e *Event) Fields(fields Context) *Event { 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. func (e *Event) Render(l Level, message string, v ...any) string { appliedContexters := e.maybeApplyContexters() - if !e.shouldLog(l) { + if !e.Loggable(l) { return "" } e.Message = fmt.Sprintf(message, v...) @@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string { return e.String() } -// maybeLog 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) { +// Log logs the event to the defined output, or does nothing if Render returns an empty string +func (e *Event) Log(l Level, message string, v ...any) *Event { if m := e.Render(l, message, v...); m != "" { log.Println(m) } + return e } // 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, ", ")) } -func (e *Event) shouldLog(l Level) bool { - return e.globalLevelWithOverride() <= l -} - func (e *Event) globalLevelWithOverride() Level { mu.RLock() l, ov := level, overrides diff --git a/server/config.go b/server/config.go index 80eb6132..376862a1 100644 --- a/server/config.go +++ b/server/config.go @@ -47,7 +47,6 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -106,10 +105,10 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioMessagingBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string + TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string MetricsEnable bool @@ -190,7 +189,7 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests + TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", diff --git a/server/server.go b/server/server.go index 08cf08d3..fb448015 100644 --- a/server/server.go +++ b/server/server.go @@ -91,6 +91,7 @@ var ( apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" apiAccountPhonePath = "/v1/account/phone" + apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" 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) } 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! + } 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 { - 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) + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) } 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 { return s.handleStats(w, r, v) } 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 } call = readParam(r, "x-call", "call") - if call != "" && s.config.TwilioAccount == "" { + if call != "" && s.config.TwilioAccount == "" && s.userManager == nil { return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid diff --git a/server/server.yml b/server/server.yml index f11ad362..6728d6a4 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,7 +144,7 @@ # 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. +# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. # # twilio-account: # twilio-auth-token: @@ -225,17 +225,11 @@ # visitor-request-limit-exempt-hosts: "" # 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) -# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. +# every day at midnight UTC. If the limit is not set (or set to zero), the request +# limit (see above) governs the upper limit. # # 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: # - 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 diff --git a/server/server_account.go b/server/server_account.go index eb1c768f..2330eab8 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -521,7 +521,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi 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() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) 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 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 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() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { @@ -560,7 +560,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { 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 } logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") diff --git a/server/server_middleware.go b/server/server_middleware.go index e0435bb2..0e4aff7c 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -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 { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/server_twilio.go b/server/server_twilio.go index e5438133..f8067490 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/xml" "fmt" - "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/user" "heckel.io/ntfy/util" @@ -15,24 +14,26 @@ import ( ) const ( - twilioCallEndpoint = "Calls.json" - twilioCallFormat = ` + twilioCallFormat = ` - + You have a notification from notify on topic %s. Message: %s End message. - 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. Goodbye. ` ) +// 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) { if u == nil { 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("To", to) 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 { - logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") +func (s *Server) callPhoneInternal(data url.Values) (string, error) { + 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.Set("To", phoneNumber) data.Set("Channel", "sms") @@ -86,21 +114,16 @@ func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) er 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") - } + ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received 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") +func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") data := url.Values{} data.Set("To", phoneNumber) 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.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) if err != nil { return err @@ -144,54 +163,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code 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 { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(text)) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 133138f3..5b320959 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -11,37 +11,108 @@ import ( "testing" ) -func TestServer_Twilio_SMS(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { + var called, verified atomic.Bool + var code atomic.Pointer[string] + twilioVerifyServer := 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)) + 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) })) - defer twilioServer.Close() + defer twilioCallsServer.Close() - c := newTestConfig(t) - c.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c := newTestConfigWithAuthFile(t) + c.TwilioVerifyBaseURL = twilioVerifyServer.URL + c.TwilioCallsBaseURL = twilioCallsServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" - c.VisitorSMSDailyLimit = 1 + c.TwilioVerifyService = "VA1234567890" s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "SMS": "+11122233344", + // 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) + + // 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 { 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 twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -49,16 +120,15 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { } 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, "/2010-04-01/Accounts/AC1234567890/Calls.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+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) })) defer twilioServer.Close() c := newTestConfigWithAuthFile(t) - c.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -68,62 +138,26 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "pro", MessageLimit: 10, - SMSLimit: 1, + 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 request with user - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "SMS": "+11122233344", + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", }) - require.Equal(t, "test", 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 ! -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) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) waitFor(t, func() bool { 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 twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -133,13 +167,44 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { 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%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) })) defer twilioServer.Close() 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.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -155,19 +220,16 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { require.Nil(t, s.userManager.ChangeTier("phil", "pro")) // 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"), "x-call": "+11122233344", }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) + require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioMessagingBaseURL = "https://127.0.0.1" + c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" 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{ "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.TwilioMessagingBaseURL = "https://127.0.0.1" + c.TwilioCallsBaseURL = "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", + "x-call": "+123123", }) - 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) + require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) } 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{ "x-call": "+1234", }) - require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) } diff --git a/server/types.go b/server/types.go index cbf1df9a..8fd75176 100644 --- a/server/types.go +++ b/server/types.go @@ -326,7 +326,6 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` - SMS int64 `json:"sms"` Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` @@ -340,8 +339,6 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` - SMS int64 `json:"sms"` - SMSRemaining int64 `json:"sms_remaining"` Calls int64 `json:"calls"` CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 7d8affc0..f2120e5f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -130,7 +130,7 @@ "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", "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_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 21b3f810..b5bfcd29 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,7 +1,7 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, accountPhoneUrl, + accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, @@ -299,8 +299,8 @@ class AccountApi { return await response.json(); // May throw SyntaxError } - async verifyPhone(phoneNumber) { - const url = accountPhoneUrl(config.base_url); + async verifyPhoneNumber(phoneNumber) { + const url = accountPhoneVerifyUrl(config.base_url); console.log(`[AccountApi] Sending phone verification ${url}`); await fetchOrThrow(url, { method: "PUT", @@ -311,11 +311,11 @@ class AccountApi { }); } - async checkVerifyPhone(phoneNumber, code) { + async addPhoneNumber(phoneNumber, code) { 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, { - method: "POST", + method: "PUT", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ number: phoneNumber, diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6e044913..346df37f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -28,6 +28,7 @@ export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/ac export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; 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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 28d24a38..b4a378e6 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -432,7 +432,7 @@ const AddPhoneNumberDialog = (props) => { const verifyPhone = async () => { try { setSending(true); - await accountApi.verifyPhone(phoneNumber); + await accountApi.verifyPhoneNumber(phoneNumber); setVerificationCodeSent(true); } catch (e) { console.log(`[Account] Error sending verification`, e); @@ -449,7 +449,7 @@ const AddPhoneNumberDialog = (props) => { const checkVerifyPhone = async () => { try { setSending(true); - await accountApi.checkVerifyPhone(phoneNumber, code); + await accountApi.addPhoneNumber(phoneNumber, code); props.onClose(); } catch (e) { console.log(`[Account] Error confirming verification`, e); diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index c410f19d..0353abe7 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,7 +45,6 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); - const [sms, setSms] = useState(""); const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -54,7 +53,6 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); - const [showSms, setShowSms] = useState(false); const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); @@ -128,9 +126,6 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } - if (sms.trim()) { - url.searchParams.append("sms", sms.trim()); - } if (call.trim()) { url.searchParams.append("call", call.trim()); } @@ -416,27 +411,6 @@ const PublishDialog = (props) => { /> } - {showSms && - { - setSms(""); - setShowSms(false); - }}> - setSms(ev.target.value)} - disabled={disabled} - type="tel" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_sms_label") - }} - /> - - } {showCall && { setCall(""); @@ -562,7 +536,6 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}