Update web app with SMS and calls stuff

This commit is contained in:
binwiederhier 2023-05-07 22:28:07 -04:00
parent 7677c50b0e
commit eb0805a470
14 changed files with 274 additions and 39 deletions

View file

@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## ntfy server v2.5.0 (UNRELEASED) ## ntfy server v2.5.0 (UNRELEASED)
**Features:**
* Support for SMS and voice calls using Twilio (no ticket)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
* Removed old ntfy website from ntfy entirely (no ticket) * Removed old ntfy website from ntfy entirely (no ticket)

View file

@ -529,6 +529,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup, EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "", EnablePayments: s.config.StripeSecretKey != "",
EnableSMS: s.config.TwilioAccount != "",
EnableCalls: s.config.TwilioAccount != "",
EnableReservations: s.config.EnableReservations, EnableReservations: s.config.EnableReservations,
BillingContact: s.config.BillingContact, BillingContact: s.config.BillingContact,
DisallowedTopics: s.config.DisallowedTopics, DisallowedTopics: s.config.DisallowedTopics,

View file

@ -68,6 +68,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Messages: freeTier.MessageLimit, Messages: freeTier.MessageLimit,
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
Emails: freeTier.EmailLimit, Emails: freeTier.EmailLimit,
SMS: freeTier.SMSLimit,
Calls: freeTier.CallLimit,
Reservations: freeTier.ReservationsLimit, Reservations: freeTier.ReservationsLimit,
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
AttachmentFileSize: freeTier.AttachmentFileSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
@ -96,6 +98,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Messages: tier.MessageLimit, Messages: tier.MessageLimit,
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
Emails: tier.EmailLimit, Emails: tier.EmailLimit,
SMS: tier.SMSLimit,
Calls: tier.CallLimit,
Reservations: tier.ReservationLimit, Reservations: tier.ReservationLimit,
AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
AttachmentFileSize: tier.AttachmentFileSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit,

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net/http" "net/http"
@ -32,7 +33,7 @@ const (
) )
func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { 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)) body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m))
data := url.Values{} data := url.Values{}
data.Set("From", s.config.TwilioFromNumber) data.Set("From", s.config.TwilioFromNumber)
data.Set("To", to) data.Set("To", to)
@ -41,7 +42,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
} }
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) {
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(m))) body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m)))
data := url.Values{} data := url.Values{}
data.Set("From", s.config.TwilioFromNumber) data.Set("From", s.config.TwilioFromNumber)
data.Set("To", to) data.Set("To", to)
@ -97,11 +98,11 @@ func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values)
return string(response), nil return string(response), nil
} }
func (s *Server) messageFooter(m *message) string { func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil!
topicURL := s.config.BaseURL + "/" + m.Topic topicURL := s.config.BaseURL + "/" + m.Topic
sender := m.Sender.String() sender := m.Sender.String()
if m.User != "" { if u != nil {
sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender)
} }
return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL))
} }

View file

@ -2,6 +2,8 @@ package server
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -27,6 +29,7 @@ func TestServer_Twilio_SMS(t *testing.T) {
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
c.VisitorSMSDailyLimit = 1
s := newTestServer(t, c) s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{ response := request(t, s, "POST", "/mytopic", "test", map[string]string{
@ -38,6 +41,56 @@ func TestServer_Twilio_SMS(t *testing.T) {
}) })
} }
func TestServer_Twilio_SMS_With_User(t *testing.T) {
var called atomic.Bool
twilioServer := 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/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+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.BaseURL = "https://ntfy.sh"
c.TwilioBaseURL = 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,
SMSLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do request with user
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
"SMS": "+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) { func TestServer_Twilio_Call(t *testing.T) {
var called atomic.Bool var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -55,6 +108,7 @@ func TestServer_Twilio_Call(t *testing.T) {
c.TwilioAccount = "AC1234567890" c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890" c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioFromNumber = "+1234567890" c.TwilioFromNumber = "+1234567890"
c.VisitorCallDailyLimit = 1
s := newTestServer(t, c) s := newTestServer(t, c)
body := `this message has body := `this message has
@ -69,6 +123,48 @@ and "quotes and other 'quotes`
}) })
} }
func TestServer_Twilio_Call_With_User(t *testing.T) {
var called atomic.Bool
twilioServer := 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=%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))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioBaseURL = 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"))
// 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, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
}
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.TwilioBaseURL = "https://127.0.0.1"

View file

@ -351,6 +351,8 @@ type apiConfigResponse struct {
EnableLogin bool `json:"enable_login"` EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"` EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableSMS bool `json:"enable_sms"`
EnableCalls bool `json:"enable_calls"`
EnableReservations bool `json:"enable_reservations"` EnableReservations bool `json:"enable_reservations"`
BillingContact string `json:"billing_contact"` BillingContact string `json:"billing_contact"`
DisallowedTopics []string `json:"disallowed_topics"` DisallowedTopics []string `json:"disallowed_topics"`

View file

@ -127,26 +127,26 @@ const (
` `
selectUserByIDQuery = ` selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ? WHERE u.id = ?
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
JOIN user_token tk on u.id = tk.user_id JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
` `
selectUserByStripeCustomerIDQuery = ` selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ? WHERE u.stripe_customer_id = ?
@ -927,11 +927,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
var id, username, hash, role, prefs, syncTopic string var id, username, hash, role, prefs, syncTopic string
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, sms, calls int64 var messages, emails, sms, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -971,6 +971,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
MessageLimit: messagesLimit.Int64, MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64, EmailLimit: emailsLimit.Int64,
SMSLimit: smsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64, ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,

View file

@ -6,12 +6,14 @@
// During web development, you may change values here for rapid testing. // During web development, you may change values here for rapid testing.
var config = { var config = {
base_url: window.location.origin, // Change to test against a different server base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server
app_root: "/app", app_root: "/app",
enable_login: true, enable_login: true,
enable_signup: true, enable_signup: true,
enable_payments: true, enable_payments: true,
enable_reservations: true, enable_reservations: true,
enable_sms: true,
enable_calls: true,
billing_contact: "", billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
}; };

View file

@ -127,6 +127,12 @@
"publish_dialog_email_label": "Email", "publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
"publish_dialog_email_reset": "Remove email forward", "publish_dialog_email_reset": "Remove email forward",
"publish_dialog_sms_label": "SMS",
"publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444",
"publish_dialog_sms_reset": "Remove SMS message",
"publish_dialog_call_label": "Phone call",
"publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444",
"publish_dialog_call_reset": "Remove phone call",
"publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_label": "Attachment URL",
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Remove attachment URL", "publish_dialog_attach_reset": "Remove attachment URL",
@ -138,6 +144,8 @@
"publish_dialog_other_features": "Other features:", "publish_dialog_other_features": "Other features:",
"publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Forward to email", "publish_dialog_chip_email_label": "Forward to email",
"publish_dialog_chip_sms_label": "Send SMS",
"publish_dialog_chip_call_label": "Phone call",
"publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_url_label": "Attach file by URL",
"publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_attach_file_label": "Attach local file",
"publish_dialog_chip_delay_label": "Delay delivery", "publish_dialog_chip_delay_label": "Delay delivery",
@ -203,6 +211,10 @@
"account_basics_tier_manage_billing_button": "Manage billing", "account_basics_tier_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_sms_title": "SMS sent",
"account_usage_sms_none": "No SMS can be sent with this account",
"account_usage_calls_title": "Phone calls made",
"account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account", "account_usage_reservations_none": "No reserved topics for this account",
"account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_title": "Attachment storage",
@ -232,6 +244,12 @@
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
"account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS",
"account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS",
"account_upgrade_dialog_tier_features_no_sms": "No daily SMS",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
"account_upgrade_dialog_tier_features_no_calls": "No daily phone calls",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
"account_upgrade_dialog_tier_price_per_month": "month", "account_upgrade_dialog_tier_price_per_month": "month",

View file

@ -206,10 +206,12 @@ export const formatBytes = (bytes, decimals = 2) => {
} }
export const formatNumber = (n) => { export const formatNumber = (n) => {
if (n % 1000 === 0) { if (n === 0) {
return n;
} else if (n % 1000 === 0) {
return `${n/1000}k`; return `${n/1000}k`;
} }
return n; return n.toLocaleString();
} }
export const formatPrice = (n) => { export const formatPrice = (n) => {

View file

@ -51,6 +51,7 @@ import {ContentCopy, Public} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import DialogContentText from "@mui/material/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
import {ProChip} from "./SubscriptionPopup";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -337,23 +338,18 @@ const Stats = () => {
{t("account_usage_title")} {t("account_usage_title")}
</Typography> </Typography>
<PrefGroup> <PrefGroup>
<Pref title={t("account_usage_reservations_title")}>
{(account.role === Role.ADMIN || account.limits.reservations > 0) && {(account.role === Role.ADMIN || account.limits.reservations > 0) &&
<> <Pref title={t("account_usage_reservations_title")}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
/> />
</>
}
{account.role === Role.USER && account.limits.reservations === 0 &&
<em>{t("account_usage_reservations_none")}</em>
}
</Pref> </Pref>
}
<Pref title={ <Pref title={
<> <>
{t("account_usage_messages_title")} {t("account_usage_messages_title")}
@ -361,8 +357,8 @@ const Stats = () => {
</> </>
}> }>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
@ -376,14 +372,48 @@ const Stats = () => {
</> </>
}> }>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100} value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
/> />
</Pref> </Pref>
{(account.role === Role.ADMIN || account.limits.sms > 0) &&
<Pref title={
<>
{t("account_usage_sms_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.sms.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.sms, account.limits.sms) : 100}
/>
</Pref>
}
{(account.role === Role.ADMIN || account.limits.calls > 0) &&
<Pref title={
<>
{t("account_usage_calls_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.calls.toLocaleString()}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
/>
</Pref>
}
<Pref <Pref
alignTop alignTop
title={t("account_usage_attachment_storage_title")} title={t("account_usage_attachment_storage_title")}
@ -404,6 +434,21 @@ const Stats = () => {
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
/> />
</Pref> </Pref>
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 &&
<Pref title={<>{t("account_usage_reservations_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_reservations_none")}</em>
</Pref>
}
{config.enable_sms && account.role === Role.USER && account.limits.sms === 0 &&
<Pref title={<>{t("account_usage_sms_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_sms_none")}</em>
</Pref>
}
{config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
<Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
<em>{t("account_usage_calls_none")}</em>
</Pref>
}
</PrefGroup> </PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && {account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
<Typography variant="body1"> <Typography variant="body1">

View file

@ -45,6 +45,8 @@ const PublishDialog = (props) => {
const [filename, setFilename] = useState(""); const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false); const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sms, setSms] = useState("");
const [call, setCall] = useState("");
const [delay, setDelay] = useState(""); const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false); const [publishAnother, setPublishAnother] = useState(false);
@ -52,6 +54,8 @@ const PublishDialog = (props) => {
const [showClickUrl, setShowClickUrl] = useState(false); const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
const [showSms, setShowSms] = useState(false);
const [showCall, setShowCall] = useState(false);
const [showDelay, setShowDelay] = useState(false); const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl; const showAttachFile = !!attachFile && !showAttachUrl;
@ -124,6 +128,12 @@ const PublishDialog = (props) => {
if (email.trim()) { if (email.trim()) {
url.searchParams.append("email", 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());
}
if (delay.trim()) { if (delay.trim()) {
url.searchParams.append("delay", delay.trim()); url.searchParams.append("delay", delay.trim());
} }
@ -406,6 +416,48 @@ const PublishDialog = (props) => {
/> />
</ClosableRow> </ClosableRow>
} }
{showSms &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_sms_reset")} onClose={() => {
setSms("");
setShowSms(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_sms_label")}
placeholder={t("publish_dialog_sms_placeholder")}
value={sms}
onChange={ev => setSms(ev.target.value)}
disabled={disabled}
type="tel"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_sms_label")
}}
/>
</ClosableRow>
}
{showCall &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
setCall("");
setShowCall(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_call_label")}
placeholder={t("publish_dialog_call_placeholder")}
value={call}
onChange={ev => setCall(ev.target.value)}
disabled={disabled}
type="tel"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_call_label")
}}
/>
</ClosableRow>
}
{showAttachUrl && {showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
setAttachUrl(""); setAttachUrl("");
@ -510,6 +562,8 @@ const PublishDialog = (props) => {
<div> <div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showSms && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_sms_label")} aria-label={t("publish_dialog_chip_sms_label")} onClick={() => setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}

View file

@ -277,14 +277,14 @@ const LimitReachedChip = () => {
); );
}; };
const ProChip = () => { export const ProChip = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Chip <Chip
label={"ntfy Pro"} label={"ntfy Pro"}
variant="outlined" variant="outlined"
color="primary" color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }} sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/> />
); );
}; };

View file

@ -298,11 +298,14 @@ const TierCard = (props) => {
</div> </div>
<List dense> <List dense>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>} {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
{tier.limits.sms > 0 && <Feature>{t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}</Feature>}
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature> <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature> {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
{tier.limits.sms === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_sms")}</NoFeature>}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
</List> </List>
{tier.prices && props.interval === SubscriptionInterval.MONTH && {tier.prices && props.interval === SubscriptionInterval.MONTH &&
<Typography variant="body2" color="gray"> <Typography variant="body2" color="gray">