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 return ids, nil
} }
func (c *messageCache) MarkAttachmentsDeleted(ids []string) error { func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
tx, err := c.db.Begin() tx, err := c.db.Begin()
if err != nil { if err != nil {
return err return err

View file

@ -57,8 +57,9 @@ import (
- visitor with/without user - visitor with/without user
- plan-based message expiry - plan-based message expiry
- plan-based attachment expiry - plan-based attachment expiry
Docs:
- "expires" field in message
Refactor: Refactor:
- rename TopicsLimit -> ReservationsLimit
- rename /access -> /reservation - rename /access -> /reservation
Later: Later:
- Password reset - Password reset
@ -544,8 +545,8 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
if v.user != nil { if v.user != nil {
m.User = v.user.Name m.User = v.user.Name
} }
if v.user != nil && v.user.Plan != nil { if v.user != nil && v.user.Tier != nil {
m.Expires = time.Now().Unix() + v.user.Plan.MessagesExpiryDuration m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
} else { } else {
m.Expires = time.Now().Add(s.config.CacheDuration).Unix() 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 return errHTTPBadRequestAttachmentsDisallowed
} }
var attachmentExpiryDuration time.Duration var attachmentExpiryDuration time.Duration
if v.user != nil && v.user.Plan != nil { if v.user != nil && v.user.Tier != nil {
attachmentExpiryDuration = time.Duration(v.user.Plan.AttachmentExpiryDuration) * time.Second attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
} else { } else {
attachmentExpiryDuration = s.config.AttachmentExpiryDuration attachmentExpiryDuration = s.config.AttachmentExpiryDuration
} }
@ -1240,13 +1241,16 @@ func (s *Server) execManager() {
if s.fileCache != nil { if s.fileCache != nil {
ids, err := s.messageCache.AttachmentsExpired() ids, err := s.messageCache.AttachmentsExpired()
if err != nil { 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 { } else if len(ids) > 0 {
if err := s.fileCache.Remove(ids...); err != nil { if log.IsDebug() {
log.Warn("Error deleting attachments: %s", err.Error()) log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", "))
} }
if err := s.messageCache.MarkAttachmentsDeleted(ids); err != nil { if err := s.fileCache.Remove(ids...); err != nil {
log.Warn("Error marking attachments deleted: %s", err.Error()) 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 { } else {
log.Debug("Manager: No expired attachments to delete") 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, MessagesRemaining: stats.MessagesRemaining,
Emails: stats.Emails, Emails: stats.Emails,
EmailsRemaining: stats.EmailsRemaining, EmailsRemaining: stats.EmailsRemaining,
Topics: stats.Topics, Reservations: stats.Reservations,
TopicsRemaining: stats.TopicsRemaining, ReservationsRemaining: stats.ReservationsRemaining,
AttachmentTotalSize: stats.AttachmentTotalSize, AttachmentTotalSize: stats.AttachmentTotalSize,
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
}, },
@ -60,7 +60,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
Messages: stats.MessagesLimit, Messages: stats.MessagesLimit,
MessagesExpiryDuration: stats.MessagesExpiryDuration, MessagesExpiryDuration: stats.MessagesExpiryDuration,
Emails: stats.EmailsLimit, Emails: stats.EmailsLimit,
Topics: stats.TopicsLimit, Reservations: stats.ReservationsLimit,
AttachmentTotalSize: stats.AttachmentTotalSizeLimit, AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
AttachmentFileSize: stats.AttachmentFileSizeLimit, AttachmentFileSize: stats.AttachmentFileSizeLimit,
AttachmentExpiryDuration: stats.AttachmentExpiryDuration, AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
@ -80,19 +80,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
response.Subscriptions = v.user.Prefs.Subscriptions response.Subscriptions = v.user.Prefs.Subscriptions
} }
} }
if v.user.Plan != nil { if v.user.Tier != nil {
response.Plan = &apiAccountPlan{ response.Tier = &apiAccountTier{
Code: v.user.Plan.Code, Code: v.user.Tier.Code,
Upgradeable: v.user.Plan.Upgradeable, Upgradeable: v.user.Tier.Upgradeable,
} }
} else if v.user.Role == user.RoleAdmin { } else if v.user.Role == user.RoleAdmin {
response.Plan = &apiAccountPlan{ response.Tier = &apiAccountTier{
Code: string(user.PlanUnlimited), Code: string(user.TierUnlimited),
Upgradeable: false, Upgradeable: false,
} }
} else { } else {
response.Plan = &apiAccountPlan{ response.Tier = &apiAccountTier{
Code: string(user.PlanDefault), Code: string(user.TierDefault),
Upgradeable: true, Upgradeable: true,
} }
} }
@ -112,8 +112,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} else { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
response.Plan = &apiAccountPlan{ response.Tier = &apiAccountTier{
Code: string(user.PlanNone), Code: string(user.TierNone),
Upgradeable: true, Upgradeable: true,
} }
} }
@ -340,7 +340,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
if err != nil { if err != nil {
return errHTTPBadRequestPermissionInvalid return errHTTPBadRequestPermissionInvalid
} }
if v.user.Plan == nil { if v.user.Tier == nil {
return errHTTPUnauthorized // FIXME there should always be a plan! return errHTTPUnauthorized // FIXME there should always be a plan!
} }
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil { 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) reservations, err := s.userManager.ReservationsCount(v.user.Name)
if err != nil { if err != nil {
return err return err
} else if reservations >= v.user.Plan.TopicsLimit { } else if reservations >= v.user.Tier.ReservationsLimit {
return errHTTPTooManyRequestsLimitReservations return errHTTPTooManyRequestsLimitReservations
} }
} }

View file

@ -1,7 +1,6 @@
package server package server
import ( import (
"database/sql"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
@ -343,7 +342,7 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
require.Equal(t, 401, rr.Code) 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 := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) 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) 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 := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) 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) 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 := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) 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) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Create a plan (hack!) // Create a tier
db, err := sql.Open("sqlite3", conf.AuthFile) require.Nil(t, s.userManager.CreateTier(&user.Tier{
require.Nil(t, err) Code: "pro",
Upgradeable: false,
_, err = db.Exec(` MessagesLimit: 123,
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) MessagesExpiryDuration: 86400,
VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2); EmailsLimit: 32,
ReservationsLimit: 2,
UPDATE user SET plan_id = 1 WHERE user = 'phil'; AttachmentFileSizeLimit: 1231231,
`) AttachmentTotalSizeLimit: 123123,
require.Nil(t, err) AttachmentExpiryDuration: 10800,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Reserve two topics // Reserve two topics
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ 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) require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) 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, 2, len(account.Reservations))
require.Equal(t, "another", account.Reservations[0].Topic) require.Equal(t, "another", account.Reservations[0].Topic)
require.Equal(t, "write-only", account.Reservations[0].Everyone) 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) 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 := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite conf.AuthDefault = user.PermissionReadWrite
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
// Create user // Create user with tier
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Create a plan (hack!) require.Nil(t, s.userManager.CreateTier(&user.Tier{
db, err := sql.Open("sqlite3", conf.AuthFile) Code: "pro",
require.Nil(t, err) ReservationsLimit: 2,
}))
_, err = db.Exec(` require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
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)
// Reserve a topic // Reserve a topic
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ 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) 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) { func TestServer_PublishAttachment(t *testing.T) {
content := util.RandomString(5000) // > 4096 content := util.RandomString(5000) // > 4096
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
@ -1271,7 +1299,7 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
require.Equal(t, content, response.Body.String()) 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 ... time.Sleep(time.Second) // Sigh ...
s.execManager() s.execManager()
require.NoFileExists(t, file) require.NoFileExists(t, file)
@ -1279,6 +1307,99 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
require.Equal(t, 404, response.Code) 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) { func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
content := util.RandomString(5000) // > 4096 content := util.RandomString(5000) // > 4096

View file

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

View file

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

View file

@ -32,28 +32,27 @@ var (
// Manager-related queries // Manager-related queries
const ( const (
createTablesQueriesNoTx = ` createTablesQueriesNoTx = `
CREATE TABLE IF NOT EXISTS plan ( CREATE TABLE IF NOT EXISTS tier (
id INT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, code TEXT NOT NULL,
messages_limit INT NOT NULL, messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL, messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL, emails_limit INT NOT NULL,
topics_limit INT NOT NULL, reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL, attachment_expiry_duration INT NOT NULL
PRIMARY KEY (id)
); );
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INT, tier_id INT,
user TEXT NOT NULL, user TEXT NOT NULL,
pass TEXT NOT NULL, pass TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
messages INT NOT NULL DEFAULT (0), messages INT NOT NULL DEFAULT (0),
emails INT NOT NULL DEFAULT (0), emails INT NOT NULL DEFAULT (0),
settings JSON, 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 UNIQUE INDEX idx_user ON user (user);
CREATE TABLE IF NOT EXISTS user_access ( CREATE TABLE IF NOT EXISTS user_access (
@ -85,16 +84,16 @@ const (
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.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 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 = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.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 FROM user u
JOIN user_token t on u.id = t.user_id JOIN user_token t on u.id = t.user_id
LEFT JOIN plan p on p.id = u.plan_id LEFT JOIN tier p on p.id = u.tier_id
WHERE t.token = ? AND t.expires >= ? WHERE t.token = ? AND t.expires >= ?
` `
selectTopicPermsQuery = ` selectTopicPermsQuery = `
@ -178,8 +177,14 @@ const (
ORDER BY expires DESC ORDER BY expires DESC
LIMIT ? 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 // Schema management queries
@ -523,13 +528,13 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close() defer rows.Close()
var username, hash, role string var username, hash, role string
var settings, planCode sql.NullString var settings, tierCode sql.NullString
var messages, emails int64 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() { if !rows.Next() {
return nil, ErrNotFound 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 return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -549,14 +554,14 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
return nil, err return nil, err
} }
} }
if planCode.Valid { if tierCode.Valid {
user.Plan = &Plan{ user.Tier = &Tier{
Code: planCode.String, Code: tierCode.String,
Upgradeable: false, Upgradeable: false,
MessagesLimit: messagesLimit.Int64, MessagesLimit: messagesLimit.Int64,
MessagesExpiryDuration: messagesExpiryDuration.Int64, MessagesExpiryDuration: messagesExpiryDuration.Int64,
EmailsLimit: emailsLimit.Int64, EmailsLimit: emailsLimit.Int64,
TopicsLimit: topicsLimit.Int64, ReservationsLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: attachmentExpiryDuration.Int64, AttachmentExpiryDuration: attachmentExpiryDuration.Int64,
@ -678,6 +683,30 @@ func (a *Manager) ChangeRole(username string, role Role) error {
return nil 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. // 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. // 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 { func (a *Manager) CheckAllowAccess(username string, topic string) error {
@ -743,6 +772,14 @@ func (a *Manager) DefaultAccess() Permission {
return a.defaultAccess 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 { func toSQLWildcard(s string) string {
return strings.ReplaceAll(s, "*", "%") return strings.ReplaceAll(s, "*", "%")
} }

View file

@ -14,7 +14,7 @@ type User struct {
Token string // Only set if token was used to log in Token string // Only set if token was used to log in
Role Role Role Role
Prefs *Prefs Prefs *Prefs
Plan *Plan Tier *Tier
Stats *Stats Stats *Stats
} }
@ -43,27 +43,27 @@ type Prefs struct {
Subscriptions []*Subscription `json:"subscriptions,omitempty"` Subscriptions []*Subscription `json:"subscriptions,omitempty"`
} }
// PlanCode is code identifying a user's plan // TierCode is code identifying a user's tier
type PlanCode string type TierCode string
// Default plan codes // Default tier codes
const ( const (
PlanUnlimited = PlanCode("unlimited") TierUnlimited = TierCode("unlimited")
PlanDefault = PlanCode("default") TierDefault = TierCode("default")
PlanNone = PlanCode("none") TierNone = TierCode("none")
) )
// Plan represents a user's account type, including its account limits // Tier represents a user's account type, including its account limits
type Plan struct { type Tier struct {
Code string `json:"name"` Code string `json:"name"`
Upgradeable bool `json:"upgradeable"` Upgradeable bool `json:"upgradeable"`
MessagesLimit int64 `json:"messages_limit"` MessagesLimit int64 `json:"messages_limit"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
EmailsLimit int64 `json:"emails_limit"` EmailsLimit int64 `json:"emails_limit"`
TopicsLimit int64 `json:"topics_limit"` ReservationsLimit int64 `json:"reservations_limit"`
AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"`
AttachmentTotalSizeLimit int64 `json:"attachment_total_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 // Subscription represents a user's topic subscription

View file

@ -178,13 +178,13 @@
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_plan_title": "Account type", "account_usage_tier_title": "Account type",
"account_usage_plan_code_default": "Default", "account_usage_tier_code_default": "Default",
"account_usage_plan_code_unlimited": "Unlimited", "account_usage_tier_code_unlimited": "Unlimited",
"account_usage_plan_code_none": "None", "account_usage_tier_code_none": "None",
"account_usage_plan_code_pro": "Pro", "account_usage_tier_code_pro": "Pro",
"account_usage_plan_code_business": "Business", "account_usage_tier_code_business": "Business",
"account_usage_plan_code_business_plus": "Business Plus", "account_usage_tier_code_business_plus": "Business Plus",
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_topics_title": "Reserved topics", "account_usage_topics_title": "Reserved topics",

View file

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

View file

@ -99,7 +99,7 @@ const NavList = (props) => {
navigate(routes.account); 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 showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser

View file

@ -489,7 +489,7 @@ const Reservations = () => {
return <></>; return <></>;
} }
const reservations = account.reservations || []; 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 = () => { const handleAddClick = () => {
setDialogKey(prev => prev+1); setDialogKey(prev => prev+1);

View file

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