diff --git a/server/message_cache.go b/server/message_cache.go index 2e4b1f45..8e2eb06b 100644 --- a/server/message_cache.go +++ b/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 diff --git a/server/server.go b/server/server.go index 227e7e9e..59a4afdd 100644 --- a/server/server.go +++ b/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 err := s.fileCache.Remove(ids...); err != nil { - log.Warn("Error deleting attachments: %s", err.Error()) + if log.IsDebug() { + log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", ")) } - if err := s.messageCache.MarkAttachmentsDeleted(ids); err != nil { - log.Warn("Error marking attachments deleted: %s", err.Error()) + if err := s.fileCache.Remove(ids...); err != nil { + log.Warn("Manager: Error deleting attachments: %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") diff --git a/server/server_account.go b/server/server_account.go index c174a66e..6bcee5da 100644 --- a/server/server_account.go +++ b/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 } } diff --git a/server/server_account_test.go b/server/server_account_test.go index c2844384..1d662c0f 100644 --- a/server/server_account_test.go +++ b/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{ diff --git a/server/server_test.go b/server/server_test.go index 6467be9b..58235f67 100644 --- a/server/server_test.go +++ b/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 diff --git a/server/types.go b/server/types.go index b62e48a0..14ebd551 100644 --- a/server/types.go +++ b/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"` } diff --git a/server/visitor.go b/server/visitor.go index 73b1dccc..f4493d22 100644 --- a/server/visitor.go +++ b/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 diff --git a/user/manager.go b/user/manager.go index 6ae7f5d1..9baf3dc9 100644 --- a/user/manager.go +++ b/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, "*", "%") } diff --git a/user/types.go b/user/types.go index bbf07a8a..8a9e3bd0 100644 --- a/user/types.go +++ b/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 diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index c62bdaa9..02325dcc 100644 --- a/web/public/static/langs/en.json +++ b/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", diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 78b05549..9a35caa1 100644 --- a/web/src/components/Account.js +++ b/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")} - +
{account.role === "admin" ? <>{t("account_usage_unlimited")} 👑 - : t(`account_usage_plan_code_${planCode}`)} - {config.enable_payments && account.plan.upgradeable && + : t(`account_usage_tier_code_${tierCode}`)} + {config.enable_payments && account.tier.upgradeable && {" "} {}}>Upgrade @@ -199,20 +199,20 @@ const Stats = () => {
- {account.limits.topics > 0 && + {account.limits.reservations > 0 && <>
- {account.stats.topics} - {account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")} + {account.stats.reservations} + {account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}
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 && No reserved topics for this account }
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 05335dc5..82934a0b 100644 --- a/web/src/components/Navigation.js +++ b/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 diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 9e41fea2..e1777e28 100644 --- a/web/src/components/Preferences.js +++ b/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); diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 019d3c4b..783d00ec 100644 --- a/web/src/components/SubscribeDialog.js +++ b/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={ setReserveTopicVisible(ev.target.checked)} inputProps={{