Browse Source

Rename plan->tier, topics->reservations, more tests, more todos

binwiederhier 3 years ago
parent
commit
1f54adad71

+ 1 - 1
server/message_cache.go

@@ -502,7 +502,7 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
 	return ids, nil
 }
 
-func (c *messageCache) MarkAttachmentsDeleted(ids []string) error {
+func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
 	tx, err := c.db.Begin()
 	if err != nil {
 		return err

+ 13 - 9
server/server.go

@@ -57,8 +57,9 @@ import (
 		- visitor with/without user
 		- plan-based message expiry
 		- plan-based attachment expiry
+		Docs:
+		- "expires" field in message
 		Refactor:
-		- rename TopicsLimit -> ReservationsLimit
 		- rename /access -> /reservation
 		Later:
 		- Password reset
@@ -544,8 +545,8 @@ 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.Plan != nil {
-		m.Expires = time.Now().Unix() + v.user.Plan.MessagesExpiryDuration
+	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()
 	}
@@ -822,8 +823,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
 		return errHTTPBadRequestAttachmentsDisallowed
 	}
 	var attachmentExpiryDuration time.Duration
-	if v.user != nil && v.user.Plan != nil {
-		attachmentExpiryDuration = time.Duration(v.user.Plan.AttachmentExpiryDuration) * time.Second
+	if v.user != nil && v.user.Tier != nil {
+		attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
 	} else {
 		attachmentExpiryDuration = s.config.AttachmentExpiryDuration
 	}
@@ -1240,13 +1241,16 @@ func (s *Server) execManager() {
 	if s.fileCache != nil {
 		ids, err := s.messageCache.AttachmentsExpired()
 		if err != nil {
-			log.Warn("Error retrieving expired attachments: %s", err.Error())
+			log.Warn("Manager: Error retrieving expired attachments: %s", err.Error())
 		} else if len(ids) > 0 {
+			if log.IsDebug() {
+				log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", "))
+			}
 			if err := s.fileCache.Remove(ids...); err != nil {
-				log.Warn("Error deleting attachments: %s", err.Error())
+				log.Warn("Manager: Error deleting attachments: %s", err.Error())
 			}
-			if err := s.messageCache.MarkAttachmentsDeleted(ids); err != nil {
-				log.Warn("Error marking attachments deleted: %s", err.Error())
+			if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
+				log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
 			}
 		} else {
 			log.Debug("Manager: No expired attachments to delete")

+ 15 - 15
server/server_account.go

@@ -50,8 +50,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
 			MessagesRemaining:            stats.MessagesRemaining,
 			Emails:                       stats.Emails,
 			EmailsRemaining:              stats.EmailsRemaining,
-			Topics:                       stats.Topics,
-			TopicsRemaining:              stats.TopicsRemaining,
+			Reservations:                 stats.Reservations,
+			ReservationsRemaining:        stats.ReservationsRemaining,
 			AttachmentTotalSize:          stats.AttachmentTotalSize,
 			AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
 		},
@@ -60,7 +60,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
 			Messages:                 stats.MessagesLimit,
 			MessagesExpiryDuration:   stats.MessagesExpiryDuration,
 			Emails:                   stats.EmailsLimit,
-			Topics:                   stats.TopicsLimit,
+			Reservations:             stats.ReservationsLimit,
 			AttachmentTotalSize:      stats.AttachmentTotalSizeLimit,
 			AttachmentFileSize:       stats.AttachmentFileSizeLimit,
 			AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
@@ -80,19 +80,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
 				response.Subscriptions = v.user.Prefs.Subscriptions
 			}
 		}
-		if v.user.Plan != nil {
-			response.Plan = &apiAccountPlan{
-				Code:        v.user.Plan.Code,
-				Upgradeable: v.user.Plan.Upgradeable,
+		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.Plan = &apiAccountPlan{
-				Code:        string(user.PlanUnlimited),
+			response.Tier = &apiAccountTier{
+				Code:        string(user.TierUnlimited),
 				Upgradeable: false,
 			}
 		} else {
-			response.Plan = &apiAccountPlan{
-				Code:        string(user.PlanDefault),
+			response.Tier = &apiAccountTier{
+				Code:        string(user.TierDefault),
 				Upgradeable: true,
 			}
 		}
@@ -112,8 +112,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
 	} else {
 		response.Username = user.Everyone
 		response.Role = string(user.RoleAnonymous)
-		response.Plan = &apiAccountPlan{
-			Code:        string(user.PlanNone),
+		response.Tier = &apiAccountTier{
+			Code:        string(user.TierNone),
 			Upgradeable: true,
 		}
 	}
@@ -340,7 +340,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
 	if err != nil {
 		return errHTTPBadRequestPermissionInvalid
 	}
-	if v.user.Plan == nil {
+	if v.user.Tier == nil {
 		return errHTTPUnauthorized // FIXME there should always be a plan!
 	}
 	if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
@@ -354,7 +354,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
 		reservations, err := s.userManager.ReservationsCount(v.user.Name)
 		if err != nil {
 			return err
-		} else if reservations >= v.user.Plan.TopicsLimit {
+		} else if reservations >= v.user.Tier.ReservationsLimit {
 			return errHTTPTooManyRequestsLimitReservations
 		}
 	}

+ 31 - 28
server/server_account_test.go

@@ -1,7 +1,6 @@
 package server
 
 import (
-	"database/sql"
 	"fmt"
 	"github.com/stretchr/testify/require"
 	"heckel.io/ntfy/user"
@@ -343,7 +342,7 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
 	require.Equal(t, 401, rr.Code)
 }
 
-func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
+func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
 	conf := newTestConfigWithAuthFile(t)
 	conf.EnableSignup = true
 	s := newTestServer(t, conf)
@@ -357,7 +356,7 @@ func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
 	require.Equal(t, 401, rr.Code)
 }
 
-func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
+func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
 	conf := newTestConfigWithAuthFile(t)
 	conf.EnableSignup = true
 	s := newTestServer(t, conf)
@@ -370,7 +369,7 @@ func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
 	require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
 }
 
-func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
+func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
 	conf := newTestConfigWithAuthFile(t)
 	conf.EnableSignup = true
 	s := newTestServer(t, conf)
@@ -379,17 +378,19 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
 	rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
 	require.Equal(t, 200, rr.Code)
 
-	// Create a plan (hack!)
-	db, err := sql.Open("sqlite3", conf.AuthFile)
-	require.Nil(t, err)
-
-	_, err = db.Exec(`
-		INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
-		VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
-
-		UPDATE user SET plan_id = 1 WHERE user = 'phil';
-	`)
-	require.Nil(t, err)
+	// Create a tier
+	require.Nil(t, s.userManager.CreateTier(&user.Tier{
+		Code:                     "pro",
+		Upgradeable:              false,
+		MessagesLimit:            123,
+		MessagesExpiryDuration:   86400,
+		EmailsLimit:              32,
+		ReservationsLimit:        2,
+		AttachmentFileSizeLimit:  1231231,
+		AttachmentTotalSizeLimit: 123123,
+		AttachmentExpiryDuration: 10800,
+	}))
+	require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
 
 	// Reserve two topics
 	rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
@@ -420,6 +421,14 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
 	})
 	require.Equal(t, 200, rr.Code)
 	account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
+	require.Equal(t, "pro", account.Tier.Code)
+	require.Equal(t, int64(123), account.Limits.Messages)
+	require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
+	require.Equal(t, int64(32), account.Limits.Emails)
+	require.Equal(t, int64(2), account.Limits.Reservations)
+	require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
+	require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
+	require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
 	require.Equal(t, 2, len(account.Reservations))
 	require.Equal(t, "another", account.Reservations[0].Topic)
 	require.Equal(t, "write-only", account.Reservations[0].Everyone)
@@ -441,27 +450,21 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
 	require.Equal(t, "mytopic", account.Reservations[0].Topic)
 }
 
-func TestAccount_Reservation_Add_Access_By_Anonymous_Fails(t *testing.T) {
+func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
 	conf := newTestConfigWithAuthFile(t)
 	conf.AuthDefault = user.PermissionReadWrite
 	conf.EnableSignup = true
 	s := newTestServer(t, conf)
 
-	// Create user
+	// Create user with tier
 	rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
 	require.Equal(t, 200, rr.Code)
 
-	// Create a plan (hack!)
-	db, err := sql.Open("sqlite3", conf.AuthFile)
-	require.Nil(t, err)
-
-	_, err = db.Exec(`
-		INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
-		VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
-
-		UPDATE user SET plan_id = 1 WHERE user = 'phil';
-	`)
-	require.Nil(t, err)
+	require.Nil(t, s.userManager.CreateTier(&user.Tier{
+		Code:              "pro",
+		ReservationsLimit: 2,
+	}))
+	require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
 
 	// Reserve a topic
 	rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{

+ 122 - 1
server/server_test.go

@@ -1090,6 +1090,34 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) {
 	require.Equal(t, 400, response.Code)
 }
 
+func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
+	c := newTestConfigWithAuthFile(t)
+	s := newTestServer(t, c)
+
+	// Create tier with certain limits
+	require.Nil(t, s.userManager.CreateTier(&user.Tier{
+		Code:                   "test",
+		MessagesLimit:          5,
+		MessagesExpiryDuration: 1, // Second
+	}))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	require.Nil(t, s.userManager.ChangeTier("phil", "test"))
+
+	// Publish to reach message limit
+	for i := 0; i < 5; i++ {
+		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{
+			"Authorization": util.BasicAuth("phil", "phil"),
+		})
+		require.Equal(t, 200, response.Code)
+		msg := toMessage(t, response.Body.String())
+		require.True(t, msg.Expires < time.Now().Unix()+5)
+	}
+	response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 413, response.Code)
+}
+
 func TestServer_PublishAttachment(t *testing.T) {
 	content := util.RandomString(5000) // > 4096
 	s := newTestServer(t, newTestConfig(t))
@@ -1271,7 +1299,7 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, content, response.Body.String())
 
-	// DeleteMessages and makes sure it's gone
+	// Prune and makes sure it's gone
 	time.Sleep(time.Second) // Sigh ...
 	s.execManager()
 	require.NoFileExists(t, file)
@@ -1279,6 +1307,99 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
 	require.Equal(t, 404, response.Code)
 }
 
+func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
+	content := util.RandomString(5000) // > 4096
+
+	c := newTestConfigWithAuthFile(t)
+	c.AttachmentExpiryDuration = time.Millisecond // Hack
+	s := newTestServer(t, c)
+
+	// Create tier with certain limits
+	sevenDaysInSeconds := int64(604800)
+	require.Nil(t, s.userManager.CreateTier(&user.Tier{
+		Code:                     "test",
+		MessagesExpiryDuration:   sevenDaysInSeconds,
+		AttachmentFileSizeLimit:  50_000,
+		AttachmentTotalSizeLimit: 200_000,
+		AttachmentExpiryDuration: sevenDaysInSeconds, // 7 days
+	}))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	require.Nil(t, s.userManager.ChangeTier("phil", "test"))
+
+	// Publish and make sure we can retrieve it
+	response := request(t, s, "PUT", "/mytopic", content, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	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)
+	file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
+	require.FileExists(t, file)
+
+	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
+	response = request(t, s, "GET", path, "", nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, content, response.Body.String())
+
+	// Prune and makes sure it's still there
+	time.Sleep(time.Second) // Sigh ...
+	s.execManager()
+	require.FileExists(t, file)
+	response = request(t, s, "GET", path, "", nil)
+	require.Equal(t, 200, response.Code)
+}
+
+func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
+	smallFile := util.RandomString(20_000)
+	largeFile := util.RandomString(50_000)
+
+	c := newTestConfigWithAuthFile(t)
+	c.AttachmentFileSizeLimit = 20_000
+	c.VisitorAttachmentTotalSizeLimit = 40_000
+	s := newTestServer(t, c)
+
+	// Create tier with certain limits
+	require.Nil(t, s.userManager.CreateTier(&user.Tier{
+		Code:                     "test",
+		AttachmentFileSizeLimit:  50_000,
+		AttachmentTotalSizeLimit: 200_000,
+	}))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	require.Nil(t, s.userManager.ChangeTier("phil", "test"))
+
+	// Publish small file as anonymous
+	response := request(t, s, "PUT", "/mytopic", smallFile, nil)
+	msg := toMessage(t, response.Body.String())
+	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
+	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
+
+	// Publish large file as anonymous
+	response = request(t, s, "PUT", "/mytopic", largeFile, nil)
+	require.Equal(t, 413, response.Code)
+
+	// Publish too large file as phil
+	response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 413, response.Code)
+
+	// Publish large file as phil (4x)
+	for i := 0; i < 4; i++ {
+		response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
+			"Authorization": util.BasicAuth("phil", "phil"),
+		})
+		require.Equal(t, 200, response.Code)
+		msg = toMessage(t, response.Body.String())
+		require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
+		require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
+	}
+	response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 413, response.Code)
+}
+
 func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
 	content := util.RandomString(5000) // > 4096
 

+ 6 - 6
server/types.go

@@ -235,17 +235,17 @@ type apiAccountTokenResponse struct {
 	Expires int64  `json:"expires"`
 }
 
-type apiAccountPlan struct {
+type apiAccountTier struct {
 	Code        string `json:"code"`
 	Upgradeable bool   `json:"upgradeable"`
 }
 
 type apiAccountLimits struct {
-	Basis                    string `json:"basis"` // "ip", "role" or "plan"
+	Basis                    string `json:"basis"` // "ip", "role" or "tier"
 	Messages                 int64  `json:"messages"`
 	MessagesExpiryDuration   int64  `json:"messages_expiry_duration"`
 	Emails                   int64  `json:"emails"`
-	Topics                   int64  `json:"topics"`
+	Reservations             int64  `json:"reservations"`
 	AttachmentTotalSize      int64  `json:"attachment_total_size"`
 	AttachmentFileSize       int64  `json:"attachment_file_size"`
 	AttachmentExpiryDuration int64  `json:"attachment_expiry_duration"`
@@ -256,8 +256,8 @@ type apiAccountStats struct {
 	MessagesRemaining            int64 `json:"messages_remaining"`
 	Emails                       int64 `json:"emails"`
 	EmailsRemaining              int64 `json:"emails_remaining"`
-	Topics                       int64 `json:"topics"`
-	TopicsRemaining              int64 `json:"topics_remaining"`
+	Reservations                 int64 `json:"reservations"`
+	ReservationsRemaining        int64 `json:"reservations_remaining"`
 	AttachmentTotalSize          int64 `json:"attachment_total_size"`
 	AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"`
 }
@@ -274,7 +274,7 @@ type apiAccountResponse struct {
 	Notification  *user.NotificationPrefs  `json:"notification,omitempty"`
 	Subscriptions []*user.Subscription     `json:"subscriptions,omitempty"`
 	Reservations  []*apiAccountReservation `json:"reservations,omitempty"`
-	Plan          *apiAccountPlan          `json:"plan,omitempty"`
+	Tier          *apiAccountTier          `json:"tier,omitempty"`
 	Limits        *apiAccountLimits        `json:"limits,omitempty"`
 	Stats         *apiAccountStats         `json:"stats,omitempty"`
 }

+ 21 - 22
server/visitor.go

@@ -42,7 +42,7 @@ type visitor struct {
 }
 
 type visitorInfo struct {
-	Basis                        string // "ip", "role" or "plan"
+	Basis                        string // "ip", "role" or "tier"
 	Messages                     int64
 	MessagesLimit                int64
 	MessagesRemaining            int64
@@ -50,9 +50,9 @@ type visitorInfo struct {
 	Emails                       int64
 	EmailsLimit                  int64
 	EmailsRemaining              int64
-	Topics                       int64
-	TopicsLimit                  int64
-	TopicsRemaining              int64
+	Reservations                 int64
+	ReservationsLimit            int64
+	ReservationsRemaining        int64
 	AttachmentTotalSize          int64
 	AttachmentTotalSizeLimit     int64
 	AttachmentTotalSizeRemaining int64
@@ -69,9 +69,9 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
 	} else {
 		accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
 	}
-	if user != nil && user.Plan != nil {
-		requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.MessagesLimit), conf.VisitorRequestLimitBurst)
-		emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.EmailsLimit), conf.VisitorEmailLimitBurst)
+	if user != nil && user.Tier != nil {
+		requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
+		emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
 	} else {
 		requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
 		emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
@@ -183,21 +183,21 @@ func (v *visitor) Info() (*visitorInfo, error) {
 		// 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.Plan != nil {
-		info.Basis = "plan"
-		info.MessagesLimit = v.user.Plan.MessagesLimit
-		info.MessagesExpiryDuration = v.user.Plan.MessagesExpiryDuration
-		info.EmailsLimit = v.user.Plan.EmailsLimit
-		info.TopicsLimit = v.user.Plan.TopicsLimit
-		info.AttachmentTotalSizeLimit = v.user.Plan.AttachmentTotalSizeLimit
-		info.AttachmentFileSizeLimit = v.user.Plan.AttachmentFileSizeLimit
-		info.AttachmentExpiryDuration = v.user.Plan.AttachmentExpiryDuration
+	} 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.TopicsLimit = 0 // FIXME
+		info.ReservationsLimit = 0 // FIXME
 		info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
 		info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
 		info.AttachmentExpiryDuration = int64(v.config.AttachmentExpiryDuration.Seconds())
@@ -212,20 +212,19 @@ func (v *visitor) Info() (*visitorInfo, error) {
 	if err != nil {
 		return nil, err
 	}
-	var topics int64
+	var reservations int64
 	if v.user != nil && v.userManager != nil {
-		reservations, err := v.userManager.Reservations(v.user.Name) // FIXME dup call, move this to endpoint?
+		reservations, err = v.userManager.ReservationsCount(v.user.Name) // FIXME dup call, move this to endpoint?
 		if err != nil {
 			return nil, err
 		}
-		topics = int64(len(reservations))
 	}
 	info.Messages = messages
 	info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages)
 	info.Emails = emails
 	info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails)
-	info.Topics = topics
-	info.TopicsRemaining = zeroIfNegative(info.TopicsLimit - info.Topics)
+	info.Reservations = reservations
+	info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations)
 	info.AttachmentTotalSize = attachmentsBytesUsed
 	info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize)
 	return info, nil

+ 56 - 19
user/manager.go

@@ -32,28 +32,27 @@ var (
 // Manager-related queries
 const (
 	createTablesQueriesNoTx = `
-		CREATE TABLE IF NOT EXISTS plan (
-			id INT NOT NULL,		
+		CREATE TABLE IF NOT EXISTS tier (
+			id INTEGER PRIMARY KEY AUTOINCREMENT,		
 			code TEXT NOT NULL,
 			messages_limit INT NOT NULL,
 			messages_expiry_duration INT NOT NULL,
 			emails_limit INT NOT NULL,
-			topics_limit INT NOT NULL,
+			reservations_limit INT NOT NULL,
 			attachment_file_size_limit INT NOT NULL,
 			attachment_total_size_limit INT NOT NULL,
-			attachment_expiry_duration INT NOT NULL,
-			PRIMARY KEY (id)
+			attachment_expiry_duration INT NOT NULL
 		);
 		CREATE TABLE IF NOT EXISTS user (
 		    id INTEGER PRIMARY KEY AUTOINCREMENT,
-			plan_id INT,
+			tier_id INT,
 			user TEXT NOT NULL,
 			pass TEXT NOT NULL,
 			role TEXT NOT NULL,
 			messages INT NOT NULL DEFAULT (0),
 			emails INT NOT NULL DEFAULT (0),			
 			settings JSON,
-		    FOREIGN KEY (plan_id) REFERENCES plan (id)
+		    FOREIGN KEY (tier_id) REFERENCES tier (id)
 		);
 		CREATE UNIQUE INDEX idx_user ON user (user);
 		CREATE TABLE IF NOT EXISTS user_access (
@@ -85,16 +84,16 @@ 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.topics_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.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 plan p on p.id = u.plan_id
+		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.topics_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.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 plan p on p.id = u.plan_id
+		LEFT JOIN tier p on p.id = u.tier_id
 		WHERE t.token = ? AND t.expires >= ?
 	`
 	selectTopicPermsQuery = `
@@ -178,8 +177,14 @@ const (
 			ORDER BY expires DESC 
 			LIMIT ?
 		)
-;
 	`
+
+	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 (?, ?, ?, ?, ?, ?, ?, ?)
+	`
+	selectTierIDQuery   = `SELECT id FROM tier WHERE code = ?`
+	updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
 )
 
 // Schema management queries
@@ -523,13 +528,13 @@ 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, planCode sql.NullString
+	var settings, tierCode sql.NullString
 	var messages, emails int64
-	var messagesLimit, messagesExpiryDuration, emailsLimit, topicsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
+	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, &planCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &topicsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
+	if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
 		return nil, err
 	} else if err := rows.Err(); err != nil {
 		return nil, err
@@ -549,14 +554,14 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 			return nil, err
 		}
 	}
-	if planCode.Valid {
-		user.Plan = &Plan{
-			Code:                     planCode.String,
+	if tierCode.Valid {
+		user.Tier = &Tier{
+			Code:                     tierCode.String,
 			Upgradeable:              false,
 			MessagesLimit:            messagesLimit.Int64,
 			MessagesExpiryDuration:   messagesExpiryDuration.Int64,
 			EmailsLimit:              emailsLimit.Int64,
-			TopicsLimit:              topicsLimit.Int64,
+			ReservationsLimit:        reservationsLimit.Int64,
 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,
 			AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
 			AttachmentExpiryDuration: attachmentExpiryDuration.Int64,
@@ -678,6 +683,30 @@ func (a *Manager) ChangeRole(username string, role Role) error {
 	return nil
 }
 
+// ChangeTier changes a user's tier using the tier code
+func (a *Manager) ChangeTier(username, tier string) error {
+	if !AllowedUsername(username) {
+		return ErrInvalidArgument
+	}
+	rows, err := a.db.Query(selectTierIDQuery, tier)
+	if err != nil {
+		return err
+	}
+	defer rows.Close()
+	if !rows.Next() {
+		return ErrInvalidArgument
+	}
+	var tierID int64
+	if err := rows.Scan(&tierID); err != nil {
+		return err
+	}
+	rows.Close()
+	if _, err := a.db.Exec(updateUserTierQuery, tierID, username); err != nil {
+		return err
+	}
+	return nil
+}
+
 // CheckAllowAccess tests if a user may create an access control entry for the given topic.
 // If there are any ACL entries that are not owned by the user, an error is returned.
 func (a *Manager) CheckAllowAccess(username string, topic string) error {
@@ -743,6 +772,14 @@ func (a *Manager) DefaultAccess() Permission {
 	return a.defaultAccess
 }
 
+// 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 {
+		return err
+	}
+	return nil
+}
+
 func toSQLWildcard(s string) string {
 	return strings.ReplaceAll(s, "*", "%")
 }

+ 11 - 11
user/types.go

@@ -14,7 +14,7 @@ type User struct {
 	Token string // Only set if token was used to log in
 	Role  Role
 	Prefs *Prefs
-	Plan  *Plan
+	Tier  *Tier
 	Stats *Stats
 }
 
@@ -43,27 +43,27 @@ type Prefs struct {
 	Subscriptions []*Subscription    `json:"subscriptions,omitempty"`
 }
 
-// PlanCode is code identifying a user's plan
-type PlanCode string
+// TierCode is code identifying a user's tier
+type TierCode string
 
-// Default plan codes
+// Default tier codes
 const (
-	PlanUnlimited = PlanCode("unlimited")
-	PlanDefault   = PlanCode("default")
-	PlanNone      = PlanCode("none")
+	TierUnlimited = TierCode("unlimited")
+	TierDefault   = TierCode("default")
+	TierNone      = TierCode("none")
 )
 
-// Plan represents a user's account type, including its account limits
-type Plan struct {
+// 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"`
-	TopicsLimit              int64  `json:"topics_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_seconds"`
+	AttachmentExpiryDuration int64  `json:"attachment_expiry_duration"`
 }
 
 // Subscription represents a user's topic subscription

+ 7 - 7
web/public/static/langs/en.json

@@ -178,13 +178,13 @@
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_unlimited": "Unlimited",
   "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
-  "account_usage_plan_title": "Account type",
-  "account_usage_plan_code_default": "Default",
-  "account_usage_plan_code_unlimited": "Unlimited",
-  "account_usage_plan_code_none": "None",
-  "account_usage_plan_code_pro": "Pro",
-  "account_usage_plan_code_business": "Business",
-  "account_usage_plan_code_business_plus": "Business Plus",
+  "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_messages_title": "Published messages",
   "account_usage_emails_title": "Emails sent",
   "account_usage_topics_title": "Reserved topics",

+ 10 - 10
web/src/components/Account.js

@@ -169,7 +169,7 @@ const Stats = () => {
     if (!account) {
         return <></>;
     }
-    const planCode = account.plan.code ?? "none";
+    const tierCode = account.tier.code ?? "none";
     const normalize = (value, max) => Math.min(value / max * 100, 100);
     const barColor = (remaining, limit) => {
         if (account.role === "admin") {
@@ -186,12 +186,12 @@ const Stats = () => {
                 {t("account_usage_title")}
             </Typography>
             <PrefGroup>
-                <Pref title={t("account_usage_plan_title")}>
+                <Pref title={t("account_usage_tier_title")}>
                     <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_plan_code_${planCode}`)}
-                        {config.enable_payments && account.plan.upgradeable &&
+                            : t(`account_usage_tier_code_${tierCode}`)}
+                        {config.enable_payments && account.tier.upgradeable &&
                             <em>{" "}
                                 <Link onClick={() => {}}>Upgrade</Link>
                             </em>
@@ -199,20 +199,20 @@ const Stats = () => {
                     </div>
                 </Pref>
                 <Pref title={t("account_usage_topics_title")}>
-                    {account.limits.topics > 0 &&
+                    {account.limits.reservations > 0 &&
                         <>
                             <div>
-                                <Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography>
-                                <Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography>
+                                <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.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100}
-                                color={barColor(account.stats.topics_remaining, account.limits.topics)}
+                                value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
+                                color={barColor(account.stats.reservations_remaining, account.limits.reservations)}
                             />
                         </>
                     }
-                    {account.limits.topics === 0 &&
+                    {account.limits.reservations === 0 &&
                         <em>No reserved topics for this account</em>
                     }
                 </Pref>

+ 1 - 1
web/src/components/Navigation.js

@@ -99,7 +99,7 @@ const NavList = (props) => {
         navigate(routes.account);
     };
 
-    const showUpgradeBanner = config.enable_payments && (!props.account || props.account.plan.upgradeable);
+    const showUpgradeBanner = config.enable_payments && (!props.account || props.account.tier.upgradeable);
     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

+ 1 - 1
web/src/components/Preferences.js

@@ -489,7 +489,7 @@ const Reservations = () => {
         return <></>;
     }
     const reservations = account.reservations || [];
-    const limitReached = account.role === "user" && account.stats.topics_remaining === 0;
+    const limitReached = account.role === "user" && account.stats.reservations_remaining === 0;
 
     const handleAddClick = () => {
         setDialogKey(prev => prev+1);

+ 2 - 2
web/src/components/SubscribeDialog.js

@@ -87,7 +87,7 @@ const SubscribePage = (props) => {
     const existingBaseUrls = Array
         .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
         .filter(s => s !== config.base_url);
-    //const reserveTopicEnabled = session.exists() && (account?.stats.topics_remaining || 0) > 0;
+    //const reserveTopicEnabled = session.exists() && (account?.stats.reservations_remaining || 0) > 0;
 
     const handleSubscribe = async () => {
         const user = await userManager.get(baseUrl); // May be undefined
@@ -184,7 +184,7 @@ const SubscribePage = (props) => {
                             control={
                                 <Checkbox
                                     fullWidth
-                                    // disabled={account.stats.topics_remaining}
+                                    // disabled={account.stats.reservations_remaining}
                                     checked={reserveTopicVisible}
                                     onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
                                     inputProps={{