Tiers make sense for admins now

This commit is contained in:
binwiederhier 2023-01-09 15:40:46 -05:00
parent d8032e1c9e
commit 3aba7404fc
18 changed files with 457 additions and 225 deletions

View file

@ -15,6 +15,10 @@ import (
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
) )
const (
tierReset = "-"
)
func init() { func init() {
commands = append(commands, cmdUser) commands = append(commands, cmdUser)
} }
@ -110,6 +114,22 @@ user are removed, since they are no longer necessary.
Example: Example:
ntfy user change-role phil admin # Make user phil an admin ntfy user change-role phil admin # Make user phil an admin
ntfy user change-role phil user # Remove admin role from user phil ntfy user change-role phil user # Remove admin role from user phil
`,
},
{
Name: "change-tier",
Aliases: []string{"cht"},
Usage: "Changes the tier of a user",
UsageText: "ntfy user change-tier USERNAME (TIER|-)",
Action: execUserChangeTier,
Description: `Change the tier for the given user.
This command can be used to change the tier of a user. Tiers define usage limits, such
as messages per day, attachment file sizes, etc.
Example:
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
ntfy user change-tier phil - # Remove tier from user "phil" entirely
`, `,
}, },
{ {
@ -254,6 +274,37 @@ func execUserChangeRole(c *cli.Context) error {
return nil return nil
} }
func execUserChangeTier(c *cli.Context) error {
username := c.Args().Get(0)
tier := c.Args().Get(1)
if username == "" {
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
} else if !user.AllowedTier(tier) && tier != tierReset {
return errors.New("invalid tier, must be tier code, or - to reset")
} else if username == userEveryone {
return errors.New("username not allowed")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
}
if tier == tierReset {
if err := manager.ResetTier(username); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
} else {
if err := manager.ChangeTier(username, tier); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
}
return nil
}
func execUserList(c *cli.Context) error { func execUserList(c *cli.Context) error {
manager, err := createUserManager(c) manager, err := createUserManager(c)
if err != nil { if err != nil {

View file

@ -73,6 +73,7 @@ var (
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""} errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: too many messages", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}

View file

@ -36,16 +36,14 @@ import (
/* /*
TODO TODO
limits & rate limiting: Limits & rate limiting:
login/account endpoints login/account endpoints
plan:
weirdness with admin and "default" account
v.Info() endpoint double selects from DB
purge accounts that were not logged int o in X purge accounts that were not logged int o in X
reset daily limits for users reset daily Limits for users
Make sure account endpoints make sense for admins Make sure account endpoints make sense for admins
add logic to set "expires" column (this is gonna be dirty) add logic to set "expires" column (this is gonna be dirty)
UI: UI:
- Align size of message bar and upgrade banner
- flicker of upgrade banner - flicker of upgrade banner
- JS constants - JS constants
- useContext for account - useContext for account
@ -53,8 +51,10 @@ import (
- "account topic" sync mechanism - "account topic" sync mechanism
- "mute" setting - "mute" setting
- figure out what settings are "web" or "phone" - figure out what settings are "web" or "phone"
Delete visitor when tier is changed to refresh rate limiters
Tests: Tests:
- visitor with/without user - Change tier from higher to lower tier (delete reservations)
- Message rate limiting and reset tests
Docs: Docs:
- "expires" field in message - "expires" field in message
Refactor: Refactor:
@ -528,7 +528,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
return nil, err return nil, err
} }
if err := v.MessageAllowed(); err != nil { if err := v.MessageAllowed(); err != nil {
return nil, errHTTPTooManyRequestsLimitRequests // FIXME make one for messages return nil, errHTTPTooManyRequestsLimitMessages
} }
body, err := util.Peek(r.Body, s.config.MessageLimit) body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil { if err != nil {
@ -545,11 +545,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
if v.user != nil { if v.user != nil {
m.User = v.user.Name m.User = v.user.Name
} }
if v.user != nil && v.user.Tier != nil { m.Expires = time.Now().Add(v.Limits().MessagesExpiryDuration).Unix()
m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
} else {
m.Expires = time.Now().Add(s.config.CacheDuration).Unix()
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return nil, err return nil, err
} }
@ -822,24 +818,18 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed return errHTTPBadRequestAttachmentsDisallowed
} }
var attachmentExpiryDuration time.Duration vinfo, err := v.Info()
if v.user != nil && v.user.Tier != nil {
attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
} else {
attachmentExpiryDuration = s.config.AttachmentExpiryDuration
}
attachmentExpiry := time.Now().Add(attachmentExpiryDuration).Unix()
if m.Time > attachmentExpiry {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
stats, err := v.Info()
if err != nil { if err != nil {
return err return err
} }
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
if m.Time > attachmentExpiry {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
contentLengthStr := r.Header.Get("Content-Length") contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err == nil && (contentLength > stats.AttachmentTotalSizeRemaining || contentLength > stats.AttachmentFileSizeLimit) { if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
return errHTTPEntityTooLargeAttachment return errHTTPEntityTooLargeAttachment
} }
} }
@ -859,8 +849,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
} }
limiters := []util.Limiter{ limiters := []util.Limiter{
v.BandwidthLimiter(), v.BandwidthLimiter(),
util.NewFixedLimiter(stats.AttachmentFileSizeLimit), util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),
util.NewFixedLimiter(stats.AttachmentTotalSizeRemaining), util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
} }
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
if err == util.ErrLimitReached { if err == util.ErrLimitReached {

View file

@ -40,11 +40,22 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
} }
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
stats, err := v.Info() info, err := v.Info()
if err != nil { if err != nil {
return err return err
} }
limits, stats := info.Limits, info.Stats
response := &apiAccountResponse{ response := &apiAccountResponse{
Limits: &apiAccountLimits{
Basis: string(limits.Basis),
Messages: limits.MessagesLimit,
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()),
Emails: limits.EmailsLimit,
Reservations: limits.ReservationsLimit,
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
AttachmentFileSize: limits.AttachmentFileSizeLimit,
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
},
Stats: &apiAccountStats{ Stats: &apiAccountStats{
Messages: stats.Messages, Messages: stats.Messages,
MessagesRemaining: stats.MessagesRemaining, MessagesRemaining: stats.MessagesRemaining,
@ -55,16 +66,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
AttachmentTotalSize: stats.AttachmentTotalSize, AttachmentTotalSize: stats.AttachmentTotalSize,
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
}, },
Limits: &apiAccountLimits{
Basis: stats.Basis,
Messages: stats.MessagesLimit,
MessagesExpiryDuration: stats.MessagesExpiryDuration,
Emails: stats.EmailsLimit,
Reservations: stats.ReservationsLimit,
AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
AttachmentFileSize: stats.AttachmentFileSizeLimit,
AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
},
} }
if v.user != nil { if v.user != nil {
response.Username = v.user.Name response.Username = v.user.Name
@ -82,18 +83,9 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} }
if v.user.Tier != nil { if v.user.Tier != nil {
response.Tier = &apiAccountTier{ response.Tier = &apiAccountTier{
Code: v.user.Tier.Code, Code: v.user.Tier.Code,
Upgradeable: v.user.Tier.Upgradeable, Name: v.user.Tier.Name,
} Paid: v.user.Tier.Paid,
} else if v.user.Role == user.RoleAdmin {
response.Tier = &apiAccountTier{
Code: string(user.TierUnlimited),
Upgradeable: false,
}
} else {
response.Tier = &apiAccountTier{
Code: string(user.TierDefault),
Upgradeable: true,
} }
} }
reservations, err := s.userManager.Reservations(v.user.Name) reservations, err := s.userManager.Reservations(v.user.Name)
@ -112,10 +104,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} else { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
response.Tier = &apiAccountTier{
Code: string(user.TierNone),
Upgradeable: true,
}
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this

View file

@ -381,14 +381,14 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
// Create a tier // Create a tier
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "pro", Code: "pro",
Upgradeable: false, Paid: false,
MessagesLimit: 123, MessagesLimit: 123,
MessagesExpiryDuration: 86400, MessagesExpiryDuration: 86400 * time.Second,
EmailsLimit: 32, EmailsLimit: 32,
ReservationsLimit: 2, ReservationsLimit: 2,
AttachmentFileSizeLimit: 1231231, AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123, AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800, AttachmentExpiryDuration: 10800 * time.Second,
})) }))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) require.Nil(t, s.userManager.ChangeTier("phil", "pro"))

View file

@ -1098,7 +1098,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test", Code: "test",
MessagesLimit: 5, MessagesLimit: 5,
MessagesExpiryDuration: -5, // Second, what a hack! MessagesExpiryDuration: -5 * time.Second, // Second, what a hack!
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))
@ -1323,14 +1323,14 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
s := newTestServer(t, c) s := newTestServer(t, c)
// Create tier with certain limits // Create tier with certain limits
sevenDaysInSeconds := int64(604800) sevenDays := time.Duration(604800) * time.Second
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test", Code: "test",
MessagesLimit: 10, MessagesLimit: 10,
MessagesExpiryDuration: sevenDaysInSeconds, MessagesExpiryDuration: sevenDays,
AttachmentFileSizeLimit: 50_000, AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000, AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: sevenDaysInSeconds, // 7 days AttachmentExpiryDuration: sevenDays, // 7 days
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))
@ -1341,8 +1341,8 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
}) })
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
require.True(t, msg.Attachment.Expires > time.Now().Unix()+sevenDaysInSeconds-30) require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
require.True(t, msg.Expires > time.Now().Unix()+sevenDaysInSeconds-30) require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
require.FileExists(t, file) require.FileExists(t, file)
@ -1374,7 +1374,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
MessagesLimit: 100, MessagesLimit: 100,
AttachmentFileSizeLimit: 50_000, AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000, AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: 30, AttachmentExpiryDuration: 30 * time.Second,
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))

View file

@ -236,8 +236,9 @@ type apiAccountTokenResponse struct {
} }
type apiAccountTier struct { type apiAccountTier struct {
Code string `json:"code"` Code string `json:"code"`
Upgradeable bool `json:"upgradeable"` Name string `json:"name"`
Paid bool `json:"paid"`
} }
type apiAccountLimits struct { type apiAccountLimits struct {

View file

@ -38,29 +38,46 @@ type visitor struct {
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
accountLimiter *rate.Limiter // Rate limiter for account creation accountLimiter *rate.Limiter // Rate limiter for account creation
firebase time.Time // Next allowed Firebase message firebase time.Time // Next allowed Firebase message
seen time.Time seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
mu sync.Mutex mu sync.Mutex
} }
type visitorInfo struct { type visitorInfo struct {
Basis string // "ip", "role" or "tier" Limits *visitorLimits
Stats *visitorStats
}
type visitorLimits struct {
Basis visitorLimitBasis
MessagesLimit int64
MessagesExpiryDuration time.Duration
EmailsLimit int64
ReservationsLimit int64
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
AttachmentExpiryDuration time.Duration
}
type visitorStats struct {
Messages int64 Messages int64
MessagesLimit int64
MessagesRemaining int64 MessagesRemaining int64
MessagesExpiryDuration int64
Emails int64 Emails int64
EmailsLimit int64
EmailsRemaining int64 EmailsRemaining int64
Reservations int64 Reservations int64
ReservationsLimit int64
ReservationsRemaining int64 ReservationsRemaining int64
AttachmentTotalSize int64 AttachmentTotalSize int64
AttachmentTotalSizeLimit int64
AttachmentTotalSizeRemaining int64 AttachmentTotalSizeRemaining int64
AttachmentFileSizeLimit int64
AttachmentExpiryDuration int64
} }
// visitorLimitBasis describes how the visitor limits were derived, either from a user's
// IP address (default config), or from its tier
type visitorLimitBasis string
const (
visitorLimitBasisIP = visitorLimitBasis("ip")
visitorLimitBasisTier = visitorLimitBasis("tier")
)
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messagesLimiter util.Limiter var messagesLimiter util.Limiter
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
@ -82,13 +99,13 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
return &visitor{ return &visitor{
config: conf, config: conf,
messageCache: messageCache, messageCache: messageCache,
userManager: userManager, // May be nil! userManager: userManager, // May be nil
ip: ip, ip: ip,
user: user, user: user,
messages: messages, messages: messages,
emails: emails, emails: emails,
requestLimiter: requestLimiter, requestLimiter: requestLimiter,
messagesLimiter: messagesLimiter, messagesLimiter: messagesLimiter, // May be nil
emailsLimiter: emailsLimiter, emailsLimiter: emailsLimiter,
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
@ -183,37 +200,36 @@ func (v *visitor) IncrEmails() {
} }
} }
func (v *visitor) Limits() *visitorLimits {
limits := &visitorLimits{}
if v.user != nil && v.user.Tier != nil {
limits.Basis = visitorLimitBasisTier
limits.MessagesLimit = v.user.Tier.MessagesLimit
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
limits.EmailsLimit = v.user.Tier.EmailsLimit
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
} else {
limits.Basis = visitorLimitBasisIP
limits.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
limits.MessagesExpiryDuration = v.config.CacheDuration
limits.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
limits.ReservationsLimit = 0 // No reservations for anonymous users, or users without a tier
limits.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
limits.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
limits.AttachmentExpiryDuration = v.config.AttachmentExpiryDuration
}
return limits
}
func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) Info() (*visitorInfo, error) {
v.mu.Lock() v.mu.Lock()
messages := v.messages messages := v.messages
emails := v.emails emails := v.emails
v.mu.Unlock() v.mu.Unlock()
info := &visitorInfo{} var attachmentsBytesUsed int64
if v.user != nil && v.user.Role == user.RoleAdmin {
info.Basis = "role"
// All limits are zero!
info.MessagesExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
info.AttachmentExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
} else if v.user != nil && v.user.Tier != nil {
info.Basis = "tier"
info.MessagesLimit = v.user.Tier.MessagesLimit
info.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
info.EmailsLimit = v.user.Tier.EmailsLimit
info.ReservationsLimit = v.user.Tier.ReservationsLimit
info.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
info.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
info.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
} else {
info.Basis = "ip"
info.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
info.MessagesExpiryDuration = int64(v.config.CacheDuration.Seconds())
info.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
info.ReservationsLimit = 0 // FIXME
info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
info.AttachmentExpiryDuration = int64(v.config.AttachmentExpiryDuration.Seconds())
}
var attachmentsBytesUsed int64 // FIXME Maybe move this to endpoint?
var err error var err error
if v.user != nil { if v.user != nil {
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name) attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
@ -225,20 +241,26 @@ func (v *visitor) Info() (*visitorInfo, error) {
} }
var reservations int64 var reservations int64
if v.user != nil && v.userManager != nil { if v.user != nil && v.userManager != nil {
reservations, err = v.userManager.ReservationsCount(v.user.Name) // FIXME dup call, move this to endpoint? reservations, err = v.userManager.ReservationsCount(v.user.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
info.Messages = messages limits := v.Limits()
info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages) stats := &visitorStats{
info.Emails = emails Messages: messages,
info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails) MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
info.Reservations = reservations Emails: emails,
info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations) EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
info.AttachmentTotalSize = attachmentsBytesUsed Reservations: reservations,
info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize) ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
return info, nil AttachmentTotalSize: attachmentsBytesUsed,
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
}
return &visitorInfo{
Limits: limits,
Stats: stats,
}, nil
} }
func zeroIfNegative(value int64) int64 { func zeroIfNegative(value int64) int64 {

View file

@ -35,6 +35,8 @@ const (
CREATE TABLE IF NOT EXISTS tier ( CREATE TABLE IF NOT EXISTS tier (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, code TEXT NOT NULL,
name TEXT NOT NULL,
paid INT NOT NULL,
messages_limit INT NOT NULL, messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL, messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL, emails_limit INT NOT NULL,
@ -84,13 +86,13 @@ const (
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
FROM user u FROM user u
LEFT JOIN tier p on p.id = u.tier_id LEFT JOIN tier p on p.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
FROM user u FROM user u
JOIN user_token t on u.id = t.user_id JOIN user_token t on u.id = t.user_id
LEFT JOIN tier p on p.id = u.tier_id LEFT JOIN tier p on p.id = u.tier_id
@ -159,9 +161,17 @@ const (
WHERE (topic = ? OR ? LIKE topic) WHERE (topic = ? OR ? LIKE topic)
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
` `
deleteAllAccessQuery = `DELETE FROM user_access` deleteAllAccessQuery = `DELETE FROM user_access`
deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` deleteUserAccessQuery = `
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` DELETE FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?)
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
`
deleteTopicAccessQuery = `
DELETE FROM user_access
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
AND topic = ?
`
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE (SELECT id FROM user WHERE user = ?)` selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE (SELECT id FROM user WHERE user = ?)`
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
@ -180,11 +190,12 @@ const (
` `
insertTierQuery = ` insertTierQuery = `
INSERT INTO tier (code, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration) INSERT INTO tier (code, name, paid, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?` selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?` updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
) )
// Schema management queries // Schema management queries
@ -528,13 +539,14 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close() defer rows.Close()
var username, hash, role string var username, hash, role string
var settings, tierCode sql.NullString var settings, tierCode, tierName sql.NullString
var paid sql.NullBool
var messages, emails int64 var messages, emails int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrNotFound return nil, ErrNotFound
} }
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil { if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &tierName, &paid, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); 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
@ -557,14 +569,15 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
if tierCode.Valid { if tierCode.Valid {
user.Tier = &Tier{ user.Tier = &Tier{
Code: tierCode.String, Code: tierCode.String,
Upgradeable: false, Name: tierName.String,
Paid: paid.Bool,
MessagesLimit: messagesLimit.Int64, MessagesLimit: messagesLimit.Int64,
MessagesExpiryDuration: messagesExpiryDuration.Int64, MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailsLimit: emailsLimit.Int64, EmailsLimit: emailsLimit.Int64,
ReservationsLimit: reservationsLimit.Int64, ReservationsLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: attachmentExpiryDuration.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
} }
} }
return user, nil return user, nil
@ -676,7 +689,7 @@ func (a *Manager) ChangeRole(username string, role Role) error {
return err return err
} }
if role == RoleAdmin { if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
return err return err
} }
} }
@ -760,10 +773,19 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error {
_, err := a.db.Exec(deleteAllAccessQuery, username) _, err := a.db.Exec(deleteAllAccessQuery, username)
return err return err
} else if topicPattern == "" { } else if topicPattern == "" {
_, err := a.db.Exec(deleteUserAccessQuery, username) _, err := a.db.Exec(deleteUserAccessQuery, username, username)
return err return err
} }
_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) _, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
return err
}
// ResetTier removes the tier from the given user
func (a *Manager) ResetTier(username string) error {
if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument
}
_, err := a.db.Exec(deleteUserTierQuery, username)
return err return err
} }
@ -774,7 +796,7 @@ func (a *Manager) DefaultAccess() Permission {
// CreateTier creates a new tier in the database // CreateTier creates a new tier in the database
func (a *Manager) CreateTier(tier *Tier) error { func (a *Manager) CreateTier(tier *Tier) error {
if _, err := a.db.Exec(insertTierQuery, tier.Code, tier.MessagesLimit, tier.MessagesExpiryDuration, tier.EmailsLimit, tier.ReservationsLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, tier.AttachmentExpiryDuration); err != nil { if _, err := a.db.Exec(insertTierQuery, tier.Code, tier.Name, tier.Paid, tier.MessagesLimit, int64(tier.MessagesExpiryDuration.Seconds()), tier.EmailsLimit, tier.ReservationsLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds())); err != nil {
return err return err
} }
return nil return nil

View file

@ -256,6 +256,60 @@ func TestManager_ChangeRole(t *testing.T) {
require.Equal(t, 0, len(benGrants)) require.Equal(t, 0, len(benGrants))
} }
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.CreateTier(&Tier{
Code: "pro",
Name: "ntfy Pro",
Paid: true,
MessagesLimit: 5_000,
MessagesExpiryDuration: 3 * 24 * time.Hour,
EmailsLimit: 50,
ReservationsLimit: 5,
AttachmentFileSizeLimit: 52428800,
AttachmentTotalSizeLimit: 524288000,
AttachmentExpiryDuration: 24 * time.Hour,
}))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.ChangeTier("ben", "pro"))
require.Nil(t, a.AllowAccess("ben", "ben", "mytopic", true, true))
require.Nil(t, a.AllowAccess("ben", Everyone, "mytopic", false, false))
ben, err := a.User("ben")
require.Nil(t, err)
require.Equal(t, RoleUser, ben.Role)
require.Equal(t, "pro", ben.Tier.Code)
require.Equal(t, true, ben.Tier.Paid)
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, 1, len(benGrants))
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
// Switch to admin, this should remove all grants and owned ACL entries
require.Nil(t, a.ChangeRole("ben", RoleAdmin))
benGrants, err = a.Grants("ben")
require.Nil(t, err)
require.Equal(t, 0, len(benGrants))
everyoneGrants, err = a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 0, len(everyoneGrants))
}
func TestManager_Token_Valid(t *testing.T) { func TestManager_Token_Valid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser)) require.Nil(t, a.AddUser("ben", "ben", RoleUser))

View file

@ -43,27 +43,18 @@ type Prefs struct {
Subscriptions []*Subscription `json:"subscriptions,omitempty"` Subscriptions []*Subscription `json:"subscriptions,omitempty"`
} }
// TierCode is code identifying a user's tier
type TierCode string
// Default tier codes
const (
TierUnlimited = TierCode("unlimited")
TierDefault = TierCode("default")
TierNone = TierCode("none")
)
// Tier represents a user's account type, including its account limits // Tier represents a user's account type, including its account limits
type Tier struct { type Tier struct {
Code string `json:"name"` Code string
Upgradeable bool `json:"upgradeable"` Name string
MessagesLimit int64 `json:"messages_limit"` Paid bool
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesLimit int64
EmailsLimit int64 `json:"emails_limit"` MessagesExpiryDuration time.Duration
ReservationsLimit int64 `json:"reservations_limit"` EmailsLimit int64
AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` ReservationsLimit int64
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` AttachmentFileSizeLimit int64
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"` AttachmentTotalSizeLimit int64
AttachmentExpiryDuration time.Duration
} }
// Subscription represents a user's topic subscription // Subscription represents a user's topic subscription
@ -185,6 +176,7 @@ var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
) )
// AllowedRole returns true if the given role can be used for new users // AllowedRole returns true if the given role can be used for new users
@ -198,13 +190,18 @@ func AllowedUsername(username string) bool {
} }
// AllowedTopic returns true if the given topic name is valid // AllowedTopic returns true if the given topic name is valid
func AllowedTopic(username string) bool { func AllowedTopic(topic string) bool {
return allowedTopicRegex.MatchString(username) return allowedTopicRegex.MatchString(topic)
} }
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
func AllowedTopicPattern(username string) bool { func AllowedTopicPattern(topic string) bool {
return allowedTopicPatternRegex.MatchString(username) return allowedTopicPatternRegex.MatchString(topic)
}
// AllowedTier returns true if the given tier name is valid
func AllowedTier(tier string) bool {
return allowedTierRegex.MatchString(tier)
} }
// Error constants used by the package // Error constants used by the package

11
web/package-lock.json generated
View file

@ -14,6 +14,7 @@
"@mui/material": "latest", "@mui/material": "latest",
"dexie": "^3.2.1", "dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1", "dexie-react-hooks": "^1.1.1",
"humanize-duration": "^3.27.3",
"i18next": "^21.6.14", "i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0", "i18next-http-backend": "^1.4.0",
@ -8837,6 +8838,11 @@
"node": ">=10.17.0" "node": ">=10.17.0"
} }
}, },
"node_modules/humanize-duration": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.3.tgz",
"integrity": "sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw=="
},
"node_modules/i18next": { "node_modules/i18next": {
"version": "21.10.0", "version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
@ -23381,6 +23387,11 @@
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
}, },
"humanize-duration": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.3.tgz",
"integrity": "sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw=="
},
"i18next": { "i18next": {
"version": "21.10.0", "version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",

View file

@ -15,6 +15,7 @@
"@mui/material": "latest", "@mui/material": "latest",
"dexie": "^3.2.1", "dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1", "dexie-react-hooks": "^1.1.1",
"humanize-duration": "^3.27.3",
"i18next": "^21.6.14", "i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0", "i18next-http-backend": "^1.4.0",

View file

@ -179,17 +179,15 @@
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_tier_title": "Account type", "account_usage_tier_title": "Account type",
"account_usage_tier_code_default": "Default", "account_usage_tier_admin": "Admin",
"account_usage_tier_code_unlimited": "Unlimited", "account_usage_tier_none": "Basic",
"account_usage_tier_code_none": "None", "account_usage_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_code_pro": "Pro", "account_usage_tier_change_button": "Change",
"account_usage_tier_code_business": "Business",
"account_usage_tier_code_business_plus": "Business Plus",
"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_topics_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
"account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_title": "Attachment storage",
"account_usage_attachment_storage_subtitle": "{{filesize}} per file", "account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.", "account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
"account_delete_title": "Delete account", "account_delete_title": "Delete account",
"account_delete_description": "Permanently delete your account", "account_delete_description": "Permanently delete your account",

View file

@ -24,6 +24,10 @@ import accountApi, {UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import db from "../app/db"; import db from "../app/db";
import i18n from "i18next";
import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -166,10 +170,12 @@ const ChangePasswordDialog = (props) => {
const Stats = () => { const Stats = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useOutletContext(); const { account } = useOutletContext();
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
if (!account) { if (!account) {
return <></>; return <></>;
} }
const tierCode = account.tier.code ?? "none";
const normalize = (value, max) => Math.min(value / max * 100, 100); const normalize = (value, max) => Math.min(value / max * 100, 100);
const barColor = (remaining, limit) => { const barColor = (remaining, limit) => {
if (account.role === "admin") { if (account.role === "admin") {
@ -188,34 +194,63 @@ const Stats = () => {
<PrefGroup> <PrefGroup>
<Pref title={t("account_usage_tier_title")}> <Pref title={t("account_usage_tier_title")}>
<div> <div>
{account.role === "admin" {account.role === "admin" &&
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></> <>
: t(`account_usage_tier_code_${tierCode}`)} {t("account_usage_tier_admin")}
{config.enable_payments && account.tier.upgradeable && {" "}{account.tier ? `(with ${account.tier.name} tier)` : `(no tier)`}
<em>{" "} </>
<Link onClick={() => {}}>Upgrade</Link>
</em>
} }
{account.role === "user" && account.tier &&
<>{account.tier.name}</>
}
{account.role === "user" && !account.tier &&
t("account_usage_tier_none")
}
{config.enable_payments && account.role === "user" && (!account.tier || !account.tier.paid) &&
<Button
variant="outlined"
size="small"
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>}
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_upgrade_button")}</Button>
}
{config.enable_payments && account.role === "user" && account.tier?.paid &&
<Button
variant="outlined"
size="small"
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_change_button")}</Button>
}
<UpgradeDialog
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
</div> </div>
</Pref> </Pref>
<Pref title={t("account_usage_topics_title")}> {account.role !== "admin" &&
{account.limits.reservations > 0 && <Pref title={t("account_usage_reservations_title")}>
<> {account.limits.reservations > 0 &&
<div> <>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography> <div>
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2"
</div> sx={{float: "left"}}>{account.stats.reservations}</Typography>
<LinearProgress <Typography variant="body2"
variant="determinate" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} </div>
color={barColor(account.stats.reservations_remaining, account.limits.reservations)} <LinearProgress
/> variant="determinate"
</> value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
} color={barColor(account.stats.reservations_remaining, account.limits.reservations)}
{account.limits.reservations === 0 && />
<em>No reserved topics for this account</em> </>
} }
</Pref> {account.limits.reservations === 0 &&
<em>No reserved topics for this account</em>
}
</Pref>
}
<Pref title={ <Pref title={
<> <>
{t("account_usage_messages_title")} {t("account_usage_messages_title")}
@ -224,11 +259,11 @@ const Stats = () => {
}> }>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100} value={account.role === "user" ? normalize(account.stats.messages, account.limits.messages) : 100}
color={account.role === "user" && account.stats.messages_remaining === 0 ? 'error' : 'primary'} color={account.role === "user" && account.stats.messages_remaining === 0 ? 'error' : 'primary'}
/> />
</Pref> </Pref>
@ -248,14 +283,17 @@ const Stats = () => {
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'} color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
/> />
</Pref> </Pref>
<Pref title={ <Pref
<> alignTop
{t("account_usage_attachment_storage_title")} title={t("account_usage_attachment_storage_title")}
{account.role === "user" && description={t("account_usage_attachment_storage_description", {
<Tooltip title={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}><span><InfoIcon/></span></Tooltip> filesize: formatBytes(account.limits.attachment_file_size),
} expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
</> language: i18n.language,
}> fallbacks: ["en"]
})
})}
>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
@ -269,7 +307,7 @@ const Stats = () => {
</PrefGroup> </PrefGroup>
{account.limits.basis === "ip" && {account.limits.basis === "ip" &&
<Typography variant="body1"> <Typography variant="body1">
<em>{t("account_usage_basis_ip_description")}</em> {t("account_usage_basis_ip_description")}
</Typography> </Typography>
} }
</Card> </Card>

View file

@ -29,6 +29,7 @@ import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration'; import CelebrationIcon from '@mui/icons-material/Celebration';
import UpgradeDialog from "./UpgradeDialog";
const navWidth = 280; const navWidth = 280;
@ -99,7 +100,9 @@ const NavList = (props) => {
navigate(routes.account); navigate(routes.account);
}; };
const showUpgradeBanner = config.enable_payments && (!props.account || props.account.tier.upgradeable); const isAdmin = props.account?.role === "admin";
const isPaid = props.account?.tier?.paid;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;// && (!props.account || !props.account.tier || !props.account.tier.paid || props.account);
const showSubscriptionsList = props.subscriptions?.length > 0; const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
@ -154,32 +157,7 @@ const NavList = (props) => {
<ListItemText primary={t("nav_button_subscribe")}/> <ListItemText primary={t("nav_button_subscribe")}/>
</ListItemButton> </ListItemButton>
{showUpgradeBanner && {showUpgradeBanner &&
<Box sx={{ <UpgradeBanner/>
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
<Divider/>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={"Upgrade to ntfy Pro"}
secondary={"Reserve topics, more messages & emails, bigger attachments"}
primaryTypographyProps={{
style: {
fontWeight: 500,
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent"
}
}}
/>
</ListItemButton>
</Box>
} }
</List> </List>
<SubscribeDialog <SubscribeDialog
@ -193,6 +171,41 @@ const NavList = (props) => {
); );
}; };
const UpgradeBanner = () => {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<Box sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
<Divider/>
<ListItemButton onClick={() => setDialogOpen(true)}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={"Upgrade to ntfy Pro"}
secondary={"Reserve topics, more messages & emails, bigger attachments"}
primaryTypographyProps={{
style: {
fontWeight: 500,
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent"
}
}}
/>
</ListItemButton>
<UpgradeDialog
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box>
);
};
const SubscriptionList = (props) => { const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions.sort( (a, b) => { const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;

View file

@ -9,6 +9,7 @@ export const PrefGroup = (props) => {
}; };
export const Pref = (props) => { export const Pref = (props) => {
const justifyContent = (props.alignTop) ? "normal" : "center";
return ( return (
<div <div
role="row" role="row"
@ -27,7 +28,7 @@ export const Pref = (props) => {
flex: '1 0 40%', flex: '1 0 40%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: justifyContent,
paddingRight: '30px' paddingRight: '30px'
}} }}
> >
@ -40,7 +41,7 @@ export const Pref = (props) => {
flex: '1 0 calc(60% - 50px)', flex: '1 0 calc(60% - 50px)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center' justifyContent: justifyContent
}} }}
> >
{props.children} {props.children}

View file

@ -0,0 +1,44 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
import theme from "./theme";
import api from "../app/Api";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import {useOutletContext} from "react-router-dom";
const UpgradeDialog = (props) => {
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSuccess = async () => {
// TODO
}
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>Upgrade to Pro</DialogTitle>
<DialogContent>
Content
</DialogContent>
<DialogFooter>
Footer
</DialogFooter>
</Dialog>
);
};
export default UpgradeDialog;