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

This commit is contained in:
binwiederhier 2023-01-07 21:04:13 -05:00
parent df512d0ba2
commit 1f54adad71
14 changed files with 298 additions and 134 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "*", "%")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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