diff --git a/cmd/user.go b/cmd/user.go index 4d76e535..73678d3c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -15,6 +15,10 @@ import ( "heckel.io/ntfy/util" ) +const ( + tierReset = "-" +) + func init() { commands = append(commands, cmdUser) } @@ -110,6 +114,22 @@ user are removed, since they are no longer necessary. Example: ntfy user change-role phil admin # Make user phil an admin 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 } +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 { manager, err := createUserManager(c) if err != nil { diff --git a/server/errors.go b/server/errors.go index 3f74c434..0b6645d4 100644 --- a/server/errors.go +++ b/server/errors.go @@ -73,6 +73,7 @@ var ( 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 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", ""} 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/"} diff --git a/server/server.go b/server/server.go index 033df117..4e67787c 100644 --- a/server/server.go +++ b/server/server.go @@ -36,16 +36,14 @@ import ( /* TODO - limits & rate limiting: + Limits & rate limiting: 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 - reset daily limits for users + reset daily Limits for users Make sure account endpoints make sense for admins add logic to set "expires" column (this is gonna be dirty) UI: + - Align size of message bar and upgrade banner - flicker of upgrade banner - JS constants - useContext for account @@ -53,8 +51,10 @@ import ( - "account topic" sync mechanism - "mute" setting - figure out what settings are "web" or "phone" + Delete visitor when tier is changed to refresh rate limiters Tests: - - visitor with/without user + - Change tier from higher to lower tier (delete reservations) + - Message rate limiting and reset tests Docs: - "expires" field in message Refactor: @@ -528,7 +528,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes return nil, err } 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) if err != nil { @@ -545,11 +545,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes if v.user != nil { m.User = v.user.Name } - if v.user != nil && v.user.Tier != nil { - m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration - } else { - m.Expires = time.Now().Add(s.config.CacheDuration).Unix() - } + m.Expires = time.Now().Add(v.Limits().MessagesExpiryDuration).Unix() if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { 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 == "" { return errHTTPBadRequestAttachmentsDisallowed } - var attachmentExpiryDuration time.Duration - 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() + vinfo, err := v.Info() if err != nil { return err } + attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix() + if m.Time > attachmentExpiry { + return errHTTPBadRequestAttachmentsExpiryBeforeDelivery + } contentLengthStr := r.Header.Get("Content-Length") if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below 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 } } @@ -859,8 +849,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, } limiters := []util.Limiter{ v.BandwidthLimiter(), - util.NewFixedLimiter(stats.AttachmentFileSizeLimit), - util.NewFixedLimiter(stats.AttachmentTotalSizeRemaining), + util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit), + util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), } m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) if err == util.ErrLimitReached { diff --git a/server/server_account.go b/server/server_account.go index 6bcee5da..cc30dd63 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -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 { - stats, err := v.Info() + info, err := v.Info() if err != nil { return err } + limits, stats := info.Limits, info.Stats 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{ Messages: stats.Messages, MessagesRemaining: stats.MessagesRemaining, @@ -55,16 +66,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis AttachmentTotalSize: stats.AttachmentTotalSize, 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 { 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 { response.Tier = &apiAccountTier{ - Code: v.user.Tier.Code, - Upgradeable: v.user.Tier.Upgradeable, - } - } 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, + Code: v.user.Tier.Code, + Name: v.user.Tier.Name, + Paid: v.user.Tier.Paid, } } reservations, err := s.userManager.Reservations(v.user.Name) @@ -112,10 +104,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) - response.Tier = &apiAccountTier{ - Code: string(user.TierNone), - Upgradeable: true, - } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this diff --git a/server/server_account_test.go b/server/server_account_test.go index c9000ea7..95b89d7a 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -381,14 +381,14 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { // Create a tier require.Nil(t, s.userManager.CreateTier(&user.Tier{ Code: "pro", - Upgradeable: false, + Paid: false, MessagesLimit: 123, - MessagesExpiryDuration: 86400, + MessagesExpiryDuration: 86400 * time.Second, EmailsLimit: 32, ReservationsLimit: 2, AttachmentFileSizeLimit: 1231231, AttachmentTotalSizeLimit: 123123, - AttachmentExpiryDuration: 10800, + AttachmentExpiryDuration: 10800 * time.Second, })) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) diff --git a/server/server_test.go b/server/server_test.go index ddd8c715..28a24e2d 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1098,7 +1098,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) { require.Nil(t, s.userManager.CreateTier(&user.Tier{ Code: "test", 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.ChangeTier("phil", "test")) @@ -1323,14 +1323,14 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { s := newTestServer(t, c) // Create tier with certain limits - sevenDaysInSeconds := int64(604800) + sevenDays := time.Duration(604800) * time.Second require.Nil(t, s.userManager.CreateTier(&user.Tier{ Code: "test", MessagesLimit: 10, - MessagesExpiryDuration: sevenDaysInSeconds, + MessagesExpiryDuration: sevenDays, AttachmentFileSizeLimit: 50_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.ChangeTier("phil", "test")) @@ -1341,8 +1341,8 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { }) msg := toMessage(t, response.Body.String()) 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.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().Add(sevenDays-30*time.Second).Unix()) file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) require.FileExists(t, file) @@ -1374,7 +1374,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { MessagesLimit: 100, AttachmentFileSizeLimit: 50_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.ChangeTier("phil", "test")) diff --git a/server/types.go b/server/types.go index 14ebd551..e766171e 100644 --- a/server/types.go +++ b/server/types.go @@ -236,8 +236,9 @@ type apiAccountTokenResponse struct { } type apiAccountTier struct { - Code string `json:"code"` - Upgradeable bool `json:"upgradeable"` + Code string `json:"code"` + Name string `json:"name"` + Paid bool `json:"paid"` } type apiAccountLimits struct { diff --git a/server/visitor.go b/server/visitor.go index 4e343706..6fc66e62 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -38,29 +38,46 @@ type visitor struct { bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads accountLimiter *rate.Limiter // Rate limiter for account creation 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 } 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 - MessagesLimit int64 MessagesRemaining int64 - MessagesExpiryDuration int64 Emails int64 - EmailsLimit int64 EmailsRemaining int64 Reservations int64 - ReservationsLimit int64 ReservationsRemaining int64 AttachmentTotalSize int64 - AttachmentTotalSizeLimit 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 { var messagesLimiter util.Limiter var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter @@ -82,13 +99,13 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana return &visitor{ config: conf, messageCache: messageCache, - userManager: userManager, // May be nil! + userManager: userManager, // May be nil ip: ip, user: user, messages: messages, emails: emails, requestLimiter: requestLimiter, - messagesLimiter: messagesLimiter, + messagesLimiter: messagesLimiter, // May be nil emailsLimiter: emailsLimiter, subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), 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) { v.mu.Lock() messages := v.messages emails := v.emails v.mu.Unlock() - info := &visitorInfo{} - 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 attachmentsBytesUsed int64 var err error if v.user != nil { attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name) @@ -225,20 +241,26 @@ func (v *visitor) Info() (*visitorInfo, error) { } var reservations int64 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 { return nil, err } } - info.Messages = messages - info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages) - info.Emails = emails - info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails) - info.Reservations = reservations - info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations) - info.AttachmentTotalSize = attachmentsBytesUsed - info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize) - return info, nil + limits := v.Limits() + stats := &visitorStats{ + Messages: messages, + MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages), + Emails: emails, + EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails), + Reservations: reservations, + ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations), + AttachmentTotalSize: attachmentsBytesUsed, + AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed), + } + return &visitorInfo{ + Limits: limits, + Stats: stats, + }, nil } func zeroIfNegative(value int64) int64 { diff --git a/user/manager.go b/user/manager.go index 9baf3dc9..7037d7af 100644 --- a/user/manager.go +++ b/user/manager.go @@ -35,6 +35,8 @@ const ( CREATE TABLE IF NOT EXISTS tier ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, + name TEXT NOT NULL, + paid INT NOT NULL, messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, @@ -84,13 +86,13 @@ const ( ` 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 LEFT JOIN tier p on p.id = u.tier_id WHERE user = ? ` 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 JOIN user_token t on u.id = t.user_id LEFT JOIN tier p on p.id = u.tier_id @@ -159,9 +161,17 @@ const ( WHERE (topic = ? OR ? LIKE topic) AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) ` - deleteAllAccessQuery = `DELETE FROM user_access` - deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` - deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` + deleteAllAccessQuery = `DELETE FROM user_access` + deleteUserAccessQuery = ` + 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 = ?)` insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` @@ -180,11 +190,12 @@ const ( ` 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` selectTierIDQuery = `SELECT id FROM tier WHERE code = ?` updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?` + deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` ) // Schema management queries @@ -528,13 +539,14 @@ func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() 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 messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64 if !rows.Next() { 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 } else if err := rows.Err(); err != nil { return nil, err @@ -557,14 +569,15 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { if tierCode.Valid { user.Tier = &Tier{ Code: tierCode.String, - Upgradeable: false, + Name: tierName.String, + Paid: paid.Bool, MessagesLimit: messagesLimit.Int64, - MessagesExpiryDuration: messagesExpiryDuration.Int64, + MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailsLimit: emailsLimit.Int64, ReservationsLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - AttachmentExpiryDuration: attachmentExpiryDuration.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, } } return user, nil @@ -676,7 +689,7 @@ func (a *Manager) ChangeRole(username string, role Role) error { return err } if role == RoleAdmin { - if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { + if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil { return err } } @@ -760,10 +773,19 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error { _, err := a.db.Exec(deleteAllAccessQuery, username) return err } else if topicPattern == "" { - _, err := a.db.Exec(deleteUserAccessQuery, username) + _, err := a.db.Exec(deleteUserAccessQuery, username, username) 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 } @@ -774,7 +796,7 @@ func (a *Manager) DefaultAccess() Permission { // CreateTier creates a new tier in the database 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 nil diff --git a/user/manager_test.go b/user/manager_test.go index 611c8df4..d2784683 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -256,6 +256,60 @@ func TestManager_ChangeRole(t *testing.T) { 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) { a := newTestManager(t, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser)) diff --git a/user/types.go b/user/types.go index 8a9e3bd0..a42a47ba 100644 --- a/user/types.go +++ b/user/types.go @@ -43,27 +43,18 @@ type Prefs struct { 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 type Tier struct { - Code string `json:"name"` - Upgradeable bool `json:"upgradeable"` - MessagesLimit int64 `json:"messages_limit"` - MessagesExpiryDuration int64 `json:"messages_expiry_duration"` - EmailsLimit int64 `json:"emails_limit"` - ReservationsLimit int64 `json:"reservations_limit"` - AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` - AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` - AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"` + Code string + Name string + Paid bool + MessagesLimit int64 + MessagesExpiryDuration time.Duration + EmailsLimit int64 + ReservationsLimit int64 + AttachmentFileSizeLimit int64 + AttachmentTotalSizeLimit int64 + AttachmentExpiryDuration time.Duration } // Subscription represents a user's topic subscription @@ -185,6 +176,7 @@ var ( allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' 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 @@ -198,13 +190,18 @@ func AllowedUsername(username string) bool { } // AllowedTopic returns true if the given topic name is valid -func AllowedTopic(username string) bool { - return allowedTopicRegex.MatchString(username) +func AllowedTopic(topic string) bool { + return allowedTopicRegex.MatchString(topic) } // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) -func AllowedTopicPattern(username string) bool { - return allowedTopicPatternRegex.MatchString(username) +func AllowedTopicPattern(topic string) bool { + 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 diff --git a/web/package-lock.json b/web/package-lock.json index 014b316c..b57c9f88 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", + "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", @@ -8837,6 +8838,11 @@ "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": { "version": "21.10.0", "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", "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": { "version": "21.10.0", "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", diff --git a/web/package.json b/web/package.json index e97191b0..9e919ef7 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", + "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 02325dcc..8f41fa7a 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -179,17 +179,15 @@ "account_usage_unlimited": "Unlimited", "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", "account_usage_tier_title": "Account type", - "account_usage_tier_code_default": "Default", - "account_usage_tier_code_unlimited": "Unlimited", - "account_usage_tier_code_none": "None", - "account_usage_tier_code_pro": "Pro", - "account_usage_tier_code_business": "Business", - "account_usage_tier_code_business_plus": "Business Plus", + "account_usage_tier_admin": "Admin", + "account_usage_tier_none": "Basic", + "account_usage_tier_upgrade_button": "Upgrade to Pro", + "account_usage_tier_change_button": "Change", "account_usage_messages_title": "Published messages", "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_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_delete_title": "Delete account", "account_delete_description": "Permanently delete your account", diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 9a35caa1..63e73385 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -24,6 +24,10 @@ import accountApi, {UnauthorizedError} from "../app/AccountApi"; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import {Pref, PrefGroup} from "./Pref"; 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 = () => { if (!session.exists()) { @@ -166,10 +170,12 @@ const ChangePasswordDialog = (props) => { const Stats = () => { const { t } = useTranslation(); const { account } = useOutletContext(); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + if (!account) { return <>; } - const tierCode = account.tier.code ?? "none"; + const normalize = (value, max) => Math.min(value / max * 100, 100); const barColor = (remaining, limit) => { if (account.role === "admin") { @@ -188,34 +194,63 @@ const Stats = () => {
- {account.role === "admin" - ? <>{t("account_usage_unlimited")} 👑 - : t(`account_usage_tier_code_${tierCode}`)} - {config.enable_payments && account.tier.upgradeable && - {" "} - {}}>Upgrade - + {account.role === "admin" && + <> + {t("account_usage_tier_admin")} + {" "}{account.tier ? `(with ${account.tier.name} tier)` : `(no tier)`} + } + {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) && + + } + {config.enable_payments && account.role === "user" && account.tier?.paid && + + } + setUpgradeDialogOpen(false)} + />
- - {account.limits.reservations > 0 && - <> -
- {account.stats.reservations} - {account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - color={barColor(account.stats.reservations_remaining, account.limits.reservations)} - /> - - } - {account.limits.reservations === 0 && - No reserved topics for this account - } -
+ {account.role !== "admin" && + + {account.limits.reservations > 0 && + <> +
+ {account.stats.reservations} + {account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + color={barColor(account.stats.reservations_remaining, account.limits.reservations)} + /> + + } + {account.limits.reservations === 0 && + No reserved topics for this account + } +
+ } {t("account_usage_messages_title")} @@ -224,11 +259,11 @@ const Stats = () => { }>
{account.stats.messages} - {account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} + {account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}
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'} />
@@ -248,14 +283,17 @@ const Stats = () => { color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'} /> - - {t("account_usage_attachment_storage_title")} - {account.role === "user" && - - } - - }> +
{formatBytes(account.stats.attachment_total_size)} {account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")} @@ -269,7 +307,7 @@ const Stats = () => { {account.limits.basis === "ip" && - {t("account_usage_basis_ip_description")} + {t("account_usage_basis_ip_description")} } diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 82934a0b..d5a20bf0 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -29,6 +29,7 @@ import {Trans, useTranslation} from "react-i18next"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; import CelebrationIcon from '@mui/icons-material/Celebration'; +import UpgradeDialog from "./UpgradeDialog"; const navWidth = 280; @@ -99,7 +100,9 @@ const NavList = (props) => { 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 showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser @@ -154,32 +157,7 @@ const NavList = (props) => { {showUpgradeBanner && - - - setSubscribeDialogOpen(true)}> - - - - - + } { ); }; +const UpgradeBanner = () => { + const [dialogOpen, setDialogOpen] = useState(false); + return ( + + + setDialogOpen(true)}> + + + + setDialogOpen(false)} + /> + + ); +}; + const SubscriptionList = (props) => { const sortedSubscriptions = props.subscriptions.sort( (a, b) => { return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; diff --git a/web/src/components/Pref.js b/web/src/components/Pref.js index 0a065ac9..622d9bbf 100644 --- a/web/src/components/Pref.js +++ b/web/src/components/Pref.js @@ -9,6 +9,7 @@ export const PrefGroup = (props) => { }; export const Pref = (props) => { + const justifyContent = (props.alignTop) ? "normal" : "center"; return (
{ flex: '1 0 40%', display: 'flex', flexDirection: 'column', - justifyContent: 'center', + justifyContent: justifyContent, paddingRight: '30px' }} > @@ -40,7 +41,7 @@ export const Pref = (props) => { flex: '1 0 calc(60% - 50px)', display: 'flex', flexDirection: 'column', - justifyContent: 'center' + justifyContent: justifyContent }} > {props.children} diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js new file mode 100644 index 00000000..392f8752 --- /dev/null +++ b/web/src/components/UpgradeDialog.js @@ -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 ( + + Upgrade to Pro + + Content + + + Footer + + + ); +}; + +export default UpgradeDialog;