Просмотр исходного кода

Tiers make sense for admins now

binwiederhier 3 лет назад
Родитель
Сommit
3aba7404fc

+ 51 - 0
cmd/user.go

@@ -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 {

+ 1 - 0
server/errors.go

@@ -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/"}

+ 15 - 25
server/server.go

@@ -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().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 {
 	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
-	if v.user != nil && v.user.Tier != nil {
-		attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
-	} else {
-		attachmentExpiryDuration = s.config.AttachmentExpiryDuration
+	vinfo, err := v.Info()
+	if err != nil {
+		return err
 	}
 	}
-	attachmentExpiry := time.Now().Add(attachmentExpiryDuration).Unix()
+	attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
 	if m.Time > attachmentExpiry {
 	if m.Time > attachmentExpiry {
 		return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
 		return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
 	}
 	}
-	stats, err := v.Info()
-	if err != nil {
-		return err
-	}
 	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(stats.AttachmentTotalSizeRemaining),
+		util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),
+		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 {

+ 15 - 27
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 {
 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,
-				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)
 		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

+ 3 - 3
server/server_account_test.go

@@ -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"))
 
 

+ 7 - 7
server/server_test.go

@@ -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.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)
 	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"))

+ 3 - 2
server/types.go

@@ -236,8 +236,9 @@ type apiAccountTokenResponse struct {
 }
 }
 
 
 type apiAccountTier 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 {
 type apiAccountLimits struct {

+ 69 - 47
server/visitor.go

@@ -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{}
-	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
 	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
-	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 {
 func zeroIfNegative(value int64) int64 {

+ 38 - 16
user/manager.go

@@ -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`
-	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 = ?)`
 	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)
-		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 = ?`
 	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

+ 54 - 0
user/manager_test.go

@@ -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))

+ 20 - 23
user/types.go

@@ -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"`
-	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
 // 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 {
-	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 (*)
 // 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
 // Error constants used by the package

+ 11 - 0
web/package-lock.json

@@ -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",

+ 1 - 0
web/package.json

@@ -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",

+ 6 - 8
web/public/static/langs/en.json

@@ -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_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_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",

+ 75 - 37
web/src/components/Account.js

@@ -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"
-                            ? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
-                            : t(`account_usage_tier_code_${tierCode}`)}
-                        {config.enable_payments && account.tier.upgradeable &&
-                            <em>{" "}
-                                <Link onClick={() => {}}>Upgrade</Link>
-                            </em>
+                        {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) &&
+                            <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.limits.reservations > 0 &&
-                        <>
-                            <div>
-                                <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
-                                <Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}</Typography>
-                            </div>
-                            <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.role !== "admin" &&
+                    <Pref title={t("account_usage_reservations_title")}>
+                        {account.limits.reservations > 0 &&
+                            <>
+                                <div>
+                                    <Typography variant="body2"
+                                                sx={{float: "left"}}>{account.stats.reservations}</Typography>
+                                    <Typography variant="body2"
+                                                sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
+                                </div>
+                                <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>
+                }
                 <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={
-                    <>
-                        {t("account_usage_attachment_storage_title")}
-                        {account.role === "user" &&
-                            <Tooltip title={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}><span><InfoIcon/></span></Tooltip>
-                        }
-                    </>
-                }>
+                <Pref
+                    alignTop
+                    title={t("account_usage_attachment_storage_title")}
+                    description={t("account_usage_attachment_storage_description", {
+                        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>

+ 40 - 27
web/src/components/Navigation.js

@@ -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={{
-                        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>
-
+                    <UpgradeBanner/>
                 }
                 }
             </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;

+ 3 - 2
web/src/components/Pref.js

@@ -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}

+ 44 - 0
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 (
+        <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
+            <DialogTitle>Upgrade to Pro</DialogTitle>
+            <DialogContent>
+                Content
+            </DialogContent>
+            <DialogFooter>
+                Footer
+            </DialogFooter>
+        </Dialog>
+    );
+};
+
+export default UpgradeDialog;