Tiers make sense for admins now
This commit is contained in:
parent
d8032e1c9e
commit
3aba7404fc
18 changed files with 457 additions and 225 deletions
51
cmd/user.go
51
cmd/user.go
|
@ -15,6 +15,10 @@ import (
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tierReset = "-"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
commands = append(commands, cmdUser)
|
commands = append(commands, cmdUser)
|
||||||
}
|
}
|
||||||
|
@ -110,6 +114,22 @@ user are removed, since they are no longer necessary.
|
||||||
Example:
|
Example:
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
ntfy user change-role phil user # Remove admin role from user phil
|
ntfy user change-role phil user # Remove admin role from user phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "change-tier",
|
||||||
|
Aliases: []string{"cht"},
|
||||||
|
Usage: "Changes the tier of a user",
|
||||||
|
UsageText: "ntfy user change-tier USERNAME (TIER|-)",
|
||||||
|
Action: execUserChangeTier,
|
||||||
|
Description: `Change the tier for the given user.
|
||||||
|
|
||||||
|
This command can be used to change the tier of a user. Tiers define usage limits, such
|
||||||
|
as messages per day, attachment file sizes, etc.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||||
|
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -254,6 +274,37 @@ func execUserChangeRole(c *cli.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execUserChangeTier(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
tier := c.Args().Get(1)
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
|
||||||
|
} else if !user.AllowedTier(tier) && tier != tierReset {
|
||||||
|
return errors.New("invalid tier, must be tier code, or - to reset")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
}
|
||||||
|
manager, err := createUserManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := manager.User(username); err == user.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
}
|
||||||
|
if tier == tierReset {
|
||||||
|
if err := manager.ResetTier(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
||||||
|
} else {
|
||||||
|
if err := manager.ChangeTier(username, tier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func execUserList(c *cli.Context) error {
|
func execUserList(c *cli.Context) error {
|
||||||
manager, err := createUserManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -73,6 +73,7 @@ var (
|
||||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
||||||
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: too many messages", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
||||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||||
|
|
|
@ -36,16 +36,14 @@ import (
|
||||||
|
|
||||||
/*
|
/*
|
||||||
TODO
|
TODO
|
||||||
limits & rate limiting:
|
Limits & rate limiting:
|
||||||
login/account endpoints
|
login/account endpoints
|
||||||
plan:
|
|
||||||
weirdness with admin and "default" account
|
|
||||||
v.Info() endpoint double selects from DB
|
|
||||||
purge accounts that were not logged int o in X
|
purge accounts that were not logged int o in X
|
||||||
reset daily limits for users
|
reset daily Limits for users
|
||||||
Make sure account endpoints make sense for admins
|
Make sure account endpoints make sense for admins
|
||||||
add logic to set "expires" column (this is gonna be dirty)
|
add logic to set "expires" column (this is gonna be dirty)
|
||||||
UI:
|
UI:
|
||||||
|
- Align size of message bar and upgrade banner
|
||||||
- flicker of upgrade banner
|
- flicker of upgrade banner
|
||||||
- JS constants
|
- JS constants
|
||||||
- useContext for account
|
- useContext for account
|
||||||
|
@ -53,8 +51,10 @@ import (
|
||||||
- "account topic" sync mechanism
|
- "account topic" sync mechanism
|
||||||
- "mute" setting
|
- "mute" setting
|
||||||
- figure out what settings are "web" or "phone"
|
- figure out what settings are "web" or "phone"
|
||||||
|
Delete visitor when tier is changed to refresh rate limiters
|
||||||
Tests:
|
Tests:
|
||||||
- visitor with/without user
|
- Change tier from higher to lower tier (delete reservations)
|
||||||
|
- Message rate limiting and reset tests
|
||||||
Docs:
|
Docs:
|
||||||
- "expires" field in message
|
- "expires" field in message
|
||||||
Refactor:
|
Refactor:
|
||||||
|
@ -528,7 +528,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := v.MessageAllowed(); err != nil {
|
if err := v.MessageAllowed(); err != nil {
|
||||||
return nil, errHTTPTooManyRequestsLimitRequests // FIXME make one for messages
|
return nil, errHTTPTooManyRequestsLimitMessages
|
||||||
}
|
}
|
||||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -545,11 +545,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||||
if v.user != nil {
|
if v.user != nil {
|
||||||
m.User = v.user.Name
|
m.User = v.user.Name
|
||||||
}
|
}
|
||||||
if v.user != nil && v.user.Tier != nil {
|
m.Expires = time.Now().Add(v.Limits().MessagesExpiryDuration).Unix()
|
||||||
m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
|
|
||||||
} else {
|
|
||||||
m.Expires = time.Now().Add(s.config.CacheDuration).Unix()
|
|
||||||
}
|
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -822,24 +818,18 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||||
return errHTTPBadRequestAttachmentsDisallowed
|
return errHTTPBadRequestAttachmentsDisallowed
|
||||||
}
|
}
|
||||||
var attachmentExpiryDuration time.Duration
|
vinfo, err := v.Info()
|
||||||
if v.user != nil && v.user.Tier != nil {
|
|
||||||
attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
|
|
||||||
} else {
|
|
||||||
attachmentExpiryDuration = s.config.AttachmentExpiryDuration
|
|
||||||
}
|
|
||||||
attachmentExpiry := time.Now().Add(attachmentExpiryDuration).Unix()
|
|
||||||
if m.Time > attachmentExpiry {
|
|
||||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
|
||||||
}
|
|
||||||
stats, err := v.Info()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
|
||||||
|
if m.Time > attachmentExpiry {
|
||||||
|
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||||
|
}
|
||||||
contentLengthStr := r.Header.Get("Content-Length")
|
contentLengthStr := r.Header.Get("Content-Length")
|
||||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||||
if err == nil && (contentLength > stats.AttachmentTotalSizeRemaining || contentLength > stats.AttachmentFileSizeLimit) {
|
if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
|
||||||
return errHTTPEntityTooLargeAttachment
|
return errHTTPEntityTooLargeAttachment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -859,8 +849,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||||
}
|
}
|
||||||
limiters := []util.Limiter{
|
limiters := []util.Limiter{
|
||||||
v.BandwidthLimiter(),
|
v.BandwidthLimiter(),
|
||||||
util.NewFixedLimiter(stats.AttachmentFileSizeLimit),
|
util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),
|
||||||
util.NewFixedLimiter(stats.AttachmentTotalSizeRemaining),
|
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
||||||
}
|
}
|
||||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||||
if err == util.ErrLimitReached {
|
if err == util.ErrLimitReached {
|
||||||
|
|
|
@ -40,11 +40,22 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
stats, err := v.Info()
|
info, err := v.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
limits, stats := info.Limits, info.Stats
|
||||||
response := &apiAccountResponse{
|
response := &apiAccountResponse{
|
||||||
|
Limits: &apiAccountLimits{
|
||||||
|
Basis: string(limits.Basis),
|
||||||
|
Messages: limits.MessagesLimit,
|
||||||
|
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()),
|
||||||
|
Emails: limits.EmailsLimit,
|
||||||
|
Reservations: limits.ReservationsLimit,
|
||||||
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
|
||||||
|
},
|
||||||
Stats: &apiAccountStats{
|
Stats: &apiAccountStats{
|
||||||
Messages: stats.Messages,
|
Messages: stats.Messages,
|
||||||
MessagesRemaining: stats.MessagesRemaining,
|
MessagesRemaining: stats.MessagesRemaining,
|
||||||
|
@ -55,16 +66,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
AttachmentTotalSize: stats.AttachmentTotalSize,
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||||
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||||
},
|
},
|
||||||
Limits: &apiAccountLimits{
|
|
||||||
Basis: stats.Basis,
|
|
||||||
Messages: stats.MessagesLimit,
|
|
||||||
MessagesExpiryDuration: stats.MessagesExpiryDuration,
|
|
||||||
Emails: stats.EmailsLimit,
|
|
||||||
Reservations: stats.ReservationsLimit,
|
|
||||||
AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
|
|
||||||
AttachmentFileSize: stats.AttachmentFileSizeLimit,
|
|
||||||
AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if v.user != nil {
|
if v.user != nil {
|
||||||
response.Username = v.user.Name
|
response.Username = v.user.Name
|
||||||
|
@ -82,18 +83,9 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
}
|
}
|
||||||
if v.user.Tier != nil {
|
if v.user.Tier != nil {
|
||||||
response.Tier = &apiAccountTier{
|
response.Tier = &apiAccountTier{
|
||||||
Code: v.user.Tier.Code,
|
Code: v.user.Tier.Code,
|
||||||
Upgradeable: v.user.Tier.Upgradeable,
|
Name: v.user.Tier.Name,
|
||||||
}
|
Paid: v.user.Tier.Paid,
|
||||||
} else if v.user.Role == user.RoleAdmin {
|
|
||||||
response.Tier = &apiAccountTier{
|
|
||||||
Code: string(user.TierUnlimited),
|
|
||||||
Upgradeable: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
response.Tier = &apiAccountTier{
|
|
||||||
Code: string(user.TierDefault),
|
|
||||||
Upgradeable: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reservations, err := s.userManager.Reservations(v.user.Name)
|
reservations, err := s.userManager.Reservations(v.user.Name)
|
||||||
|
@ -112,10 +104,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
} else {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
response.Tier = &apiAccountTier{
|
|
||||||
Code: string(user.TierNone),
|
|
||||||
Upgradeable: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
|
|
@ -381,14 +381,14 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
// Create a tier
|
// Create a tier
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
Upgradeable: false,
|
Paid: false,
|
||||||
MessagesLimit: 123,
|
MessagesLimit: 123,
|
||||||
MessagesExpiryDuration: 86400,
|
MessagesExpiryDuration: 86400 * time.Second,
|
||||||
EmailsLimit: 32,
|
EmailsLimit: 32,
|
||||||
ReservationsLimit: 2,
|
ReservationsLimit: 2,
|
||||||
AttachmentFileSizeLimit: 1231231,
|
AttachmentFileSizeLimit: 1231231,
|
||||||
AttachmentTotalSizeLimit: 123123,
|
AttachmentTotalSizeLimit: 123123,
|
||||||
AttachmentExpiryDuration: 10800,
|
AttachmentExpiryDuration: 10800 * time.Second,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
|
|
|
@ -1098,7 +1098,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
MessagesLimit: 5,
|
MessagesLimit: 5,
|
||||||
MessagesExpiryDuration: -5, // Second, what a hack!
|
MessagesExpiryDuration: -5 * time.Second, // Second, what a hack!
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
@ -1323,14 +1323,14 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Create tier with certain limits
|
// Create tier with certain limits
|
||||||
sevenDaysInSeconds := int64(604800)
|
sevenDays := time.Duration(604800) * time.Second
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
MessagesLimit: 10,
|
MessagesLimit: 10,
|
||||||
MessagesExpiryDuration: sevenDaysInSeconds,
|
MessagesExpiryDuration: sevenDays,
|
||||||
AttachmentFileSizeLimit: 50_000,
|
AttachmentFileSizeLimit: 50_000,
|
||||||
AttachmentTotalSizeLimit: 200_000,
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
AttachmentExpiryDuration: sevenDaysInSeconds, // 7 days
|
AttachmentExpiryDuration: sevenDays, // 7 days
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
@ -1341,8 +1341,8 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||||
})
|
})
|
||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.True(t, msg.Attachment.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
|
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
||||||
require.True(t, msg.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
|
require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
||||||
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||||
require.FileExists(t, file)
|
require.FileExists(t, file)
|
||||||
|
|
||||||
|
@ -1374,7 +1374,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
||||||
MessagesLimit: 100,
|
MessagesLimit: 100,
|
||||||
AttachmentFileSizeLimit: 50_000,
|
AttachmentFileSizeLimit: 50_000,
|
||||||
AttachmentTotalSizeLimit: 200_000,
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
AttachmentExpiryDuration: 30,
|
AttachmentExpiryDuration: 30 * time.Second,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
|
@ -236,8 +236,9 @@ type apiAccountTokenResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountTier struct {
|
type apiAccountTier struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Upgradeable bool `json:"upgradeable"`
|
Name string `json:"name"`
|
||||||
|
Paid bool `json:"paid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountLimits struct {
|
type apiAccountLimits struct {
|
||||||
|
|
|
@ -38,29 +38,46 @@ type visitor struct {
|
||||||
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
|
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
|
||||||
accountLimiter *rate.Limiter // Rate limiter for account creation
|
accountLimiter *rate.Limiter // Rate limiter for account creation
|
||||||
firebase time.Time // Next allowed Firebase message
|
firebase time.Time // Next allowed Firebase message
|
||||||
seen time.Time
|
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type visitorInfo struct {
|
type visitorInfo struct {
|
||||||
Basis string // "ip", "role" or "tier"
|
Limits *visitorLimits
|
||||||
|
Stats *visitorStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitorLimits struct {
|
||||||
|
Basis visitorLimitBasis
|
||||||
|
MessagesLimit int64
|
||||||
|
MessagesExpiryDuration time.Duration
|
||||||
|
EmailsLimit int64
|
||||||
|
ReservationsLimit int64
|
||||||
|
AttachmentTotalSizeLimit int64
|
||||||
|
AttachmentFileSizeLimit int64
|
||||||
|
AttachmentExpiryDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitorStats struct {
|
||||||
Messages int64
|
Messages int64
|
||||||
MessagesLimit int64
|
|
||||||
MessagesRemaining int64
|
MessagesRemaining int64
|
||||||
MessagesExpiryDuration int64
|
|
||||||
Emails int64
|
Emails int64
|
||||||
EmailsLimit int64
|
|
||||||
EmailsRemaining int64
|
EmailsRemaining int64
|
||||||
Reservations int64
|
Reservations int64
|
||||||
ReservationsLimit int64
|
|
||||||
ReservationsRemaining int64
|
ReservationsRemaining int64
|
||||||
AttachmentTotalSize int64
|
AttachmentTotalSize int64
|
||||||
AttachmentTotalSizeLimit int64
|
|
||||||
AttachmentTotalSizeRemaining int64
|
AttachmentTotalSizeRemaining int64
|
||||||
AttachmentFileSizeLimit int64
|
|
||||||
AttachmentExpiryDuration int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// visitorLimitBasis describes how the visitor limits were derived, either from a user's
|
||||||
|
// IP address (default config), or from its tier
|
||||||
|
type visitorLimitBasis string
|
||||||
|
|
||||||
|
const (
|
||||||
|
visitorLimitBasisIP = visitorLimitBasis("ip")
|
||||||
|
visitorLimitBasisTier = visitorLimitBasis("tier")
|
||||||
|
)
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||||
var messagesLimiter util.Limiter
|
var messagesLimiter util.Limiter
|
||||||
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
|
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
|
||||||
|
@ -82,13 +99,13 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
userManager: userManager, // May be nil!
|
userManager: userManager, // May be nil
|
||||||
ip: ip,
|
ip: ip,
|
||||||
user: user,
|
user: user,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
emails: emails,
|
emails: emails,
|
||||||
requestLimiter: requestLimiter,
|
requestLimiter: requestLimiter,
|
||||||
messagesLimiter: messagesLimiter,
|
messagesLimiter: messagesLimiter, // May be nil
|
||||||
emailsLimiter: emailsLimiter,
|
emailsLimiter: emailsLimiter,
|
||||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||||
|
@ -183,37 +200,36 @@ func (v *visitor) IncrEmails() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) Limits() *visitorLimits {
|
||||||
|
limits := &visitorLimits{}
|
||||||
|
if v.user != nil && v.user.Tier != nil {
|
||||||
|
limits.Basis = visitorLimitBasisTier
|
||||||
|
limits.MessagesLimit = v.user.Tier.MessagesLimit
|
||||||
|
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
||||||
|
limits.EmailsLimit = v.user.Tier.EmailsLimit
|
||||||
|
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
|
||||||
|
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
||||||
|
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
||||||
|
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
||||||
|
} else {
|
||||||
|
limits.Basis = visitorLimitBasisIP
|
||||||
|
limits.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
|
||||||
|
limits.MessagesExpiryDuration = v.config.CacheDuration
|
||||||
|
limits.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
|
||||||
|
limits.ReservationsLimit = 0 // No reservations for anonymous users, or users without a tier
|
||||||
|
limits.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
|
||||||
|
limits.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
|
||||||
|
limits.AttachmentExpiryDuration = v.config.AttachmentExpiryDuration
|
||||||
|
}
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) Info() (*visitorInfo, error) {
|
func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
messages := v.messages
|
messages := v.messages
|
||||||
emails := v.emails
|
emails := v.emails
|
||||||
v.mu.Unlock()
|
v.mu.Unlock()
|
||||||
info := &visitorInfo{}
|
var attachmentsBytesUsed int64
|
||||||
if v.user != nil && v.user.Role == user.RoleAdmin {
|
|
||||||
info.Basis = "role"
|
|
||||||
// All limits are zero!
|
|
||||||
info.MessagesExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
|
|
||||||
info.AttachmentExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
|
|
||||||
} else if v.user != nil && v.user.Tier != nil {
|
|
||||||
info.Basis = "tier"
|
|
||||||
info.MessagesLimit = v.user.Tier.MessagesLimit
|
|
||||||
info.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
|
||||||
info.EmailsLimit = v.user.Tier.EmailsLimit
|
|
||||||
info.ReservationsLimit = v.user.Tier.ReservationsLimit
|
|
||||||
info.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
|
||||||
info.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
|
||||||
info.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
|
||||||
} else {
|
|
||||||
info.Basis = "ip"
|
|
||||||
info.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
|
|
||||||
info.MessagesExpiryDuration = int64(v.config.CacheDuration.Seconds())
|
|
||||||
info.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
|
|
||||||
info.ReservationsLimit = 0 // FIXME
|
|
||||||
info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
|
|
||||||
info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
|
|
||||||
info.AttachmentExpiryDuration = int64(v.config.AttachmentExpiryDuration.Seconds())
|
|
||||||
}
|
|
||||||
var attachmentsBytesUsed int64 // FIXME Maybe move this to endpoint?
|
|
||||||
var err error
|
var err error
|
||||||
if v.user != nil {
|
if v.user != nil {
|
||||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
|
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
|
||||||
|
@ -225,20 +241,26 @@ func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
}
|
}
|
||||||
var reservations int64
|
var reservations int64
|
||||||
if v.user != nil && v.userManager != nil {
|
if v.user != nil && v.userManager != nil {
|
||||||
reservations, err = v.userManager.ReservationsCount(v.user.Name) // FIXME dup call, move this to endpoint?
|
reservations, err = v.userManager.ReservationsCount(v.user.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info.Messages = messages
|
limits := v.Limits()
|
||||||
info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages)
|
stats := &visitorStats{
|
||||||
info.Emails = emails
|
Messages: messages,
|
||||||
info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails)
|
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
|
||||||
info.Reservations = reservations
|
Emails: emails,
|
||||||
info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations)
|
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
|
||||||
info.AttachmentTotalSize = attachmentsBytesUsed
|
Reservations: reservations,
|
||||||
info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize)
|
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
|
||||||
return info, nil
|
AttachmentTotalSize: attachmentsBytesUsed,
|
||||||
|
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
|
||||||
|
}
|
||||||
|
return &visitorInfo{
|
||||||
|
Limits: limits,
|
||||||
|
Stats: stats,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func zeroIfNegative(value int64) int64 {
|
func zeroIfNegative(value int64) int64 {
|
||||||
|
|
|
@ -35,6 +35,8 @@ const (
|
||||||
CREATE TABLE IF NOT EXISTS tier (
|
CREATE TABLE IF NOT EXISTS tier (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
code TEXT NOT NULL,
|
code TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
paid INT NOT NULL,
|
||||||
messages_limit INT NOT NULL,
|
messages_limit INT NOT NULL,
|
||||||
messages_expiry_duration INT NOT NULL,
|
messages_expiry_duration INT NOT NULL,
|
||||||
emails_limit INT NOT NULL,
|
emails_limit INT NOT NULL,
|
||||||
|
@ -84,13 +86,13 @@ const (
|
||||||
`
|
`
|
||||||
|
|
||||||
selectUserByNameQuery = `
|
selectUserByNameQuery = `
|
||||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier p on p.id = u.tier_id
|
LEFT JOIN tier p on p.id = u.tier_id
|
||||||
WHERE user = ?
|
WHERE user = ?
|
||||||
`
|
`
|
||||||
selectUserByTokenQuery = `
|
selectUserByTokenQuery = `
|
||||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_token t on u.id = t.user_id
|
JOIN user_token t on u.id = t.user_id
|
||||||
LEFT JOIN tier p on p.id = u.tier_id
|
LEFT JOIN tier p on p.id = u.tier_id
|
||||||
|
@ -159,9 +161,17 @@ const (
|
||||||
WHERE (topic = ? OR ? LIKE topic)
|
WHERE (topic = ? OR ? LIKE topic)
|
||||||
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
||||||
`
|
`
|
||||||
deleteAllAccessQuery = `DELETE FROM user_access`
|
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||||
deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
deleteUserAccessQuery = `
|
||||||
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
|
DELETE FROM user_access
|
||||||
|
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
|
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
|
`
|
||||||
|
deleteTopicAccessQuery = `
|
||||||
|
DELETE FROM user_access
|
||||||
|
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||||
|
AND topic = ?
|
||||||
|
`
|
||||||
|
|
||||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE (SELECT id FROM user WHERE user = ?)`
|
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE (SELECT id FROM user WHERE user = ?)`
|
||||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
|
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
|
||||||
|
@ -180,11 +190,12 @@ const (
|
||||||
`
|
`
|
||||||
|
|
||||||
insertTierQuery = `
|
insertTierQuery = `
|
||||||
INSERT INTO tier (code, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration)
|
INSERT INTO tier (code, name, paid, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
|
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
|
||||||
updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
|
updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
|
||||||
|
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
|
@ -528,13 +539,14 @@ func (a *Manager) userByToken(token string) (*User, error) {
|
||||||
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var username, hash, role string
|
var username, hash, role string
|
||||||
var settings, tierCode sql.NullString
|
var settings, tierCode, tierName sql.NullString
|
||||||
|
var paid sql.NullBool
|
||||||
var messages, emails int64
|
var messages, emails int64
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
|
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &tierName, &paid, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -557,14 +569,15 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
if tierCode.Valid {
|
if tierCode.Valid {
|
||||||
user.Tier = &Tier{
|
user.Tier = &Tier{
|
||||||
Code: tierCode.String,
|
Code: tierCode.String,
|
||||||
Upgradeable: false,
|
Name: tierName.String,
|
||||||
|
Paid: paid.Bool,
|
||||||
MessagesLimit: messagesLimit.Int64,
|
MessagesLimit: messagesLimit.Int64,
|
||||||
MessagesExpiryDuration: messagesExpiryDuration.Int64,
|
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailsLimit: emailsLimit.Int64,
|
EmailsLimit: emailsLimit.Int64,
|
||||||
ReservationsLimit: reservationsLimit.Int64,
|
ReservationsLimit: reservationsLimit.Int64,
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||||
AttachmentExpiryDuration: attachmentExpiryDuration.Int64,
|
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return user, nil
|
return user, nil
|
||||||
|
@ -676,7 +689,7 @@ func (a *Manager) ChangeRole(username string, role Role) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if role == RoleAdmin {
|
if role == RoleAdmin {
|
||||||
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -760,10 +773,19 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error {
|
||||||
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
||||||
return err
|
return err
|
||||||
} else if topicPattern == "" {
|
} else if topicPattern == "" {
|
||||||
_, err := a.db.Exec(deleteUserAccessQuery, username)
|
_, err := a.db.Exec(deleteUserAccessQuery, username, username)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern))
|
_, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetTier removes the tier from the given user
|
||||||
|
func (a *Manager) ResetTier(username string) error {
|
||||||
|
if !AllowedUsername(username) && username != Everyone && username != "" {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
_, err := a.db.Exec(deleteUserTierQuery, username)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -774,7 +796,7 @@ func (a *Manager) DefaultAccess() Permission {
|
||||||
|
|
||||||
// CreateTier creates a new tier in the database
|
// CreateTier creates a new tier in the database
|
||||||
func (a *Manager) CreateTier(tier *Tier) error {
|
func (a *Manager) CreateTier(tier *Tier) error {
|
||||||
if _, err := a.db.Exec(insertTierQuery, tier.Code, tier.MessagesLimit, tier.MessagesExpiryDuration, tier.EmailsLimit, tier.ReservationsLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, tier.AttachmentExpiryDuration); err != nil {
|
if _, err := a.db.Exec(insertTierQuery, tier.Code, tier.Name, tier.Paid, tier.MessagesLimit, int64(tier.MessagesExpiryDuration.Seconds()), tier.EmailsLimit, tier.ReservationsLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds())); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -256,6 +256,60 @@ func TestManager_ChangeRole(t *testing.T) {
|
||||||
require.Equal(t, 0, len(benGrants))
|
require.Equal(t, 0, len(benGrants))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.CreateTier(&Tier{
|
||||||
|
Code: "pro",
|
||||||
|
Name: "ntfy Pro",
|
||||||
|
Paid: true,
|
||||||
|
MessagesLimit: 5_000,
|
||||||
|
MessagesExpiryDuration: 3 * 24 * time.Hour,
|
||||||
|
EmailsLimit: 50,
|
||||||
|
ReservationsLimit: 5,
|
||||||
|
AttachmentFileSizeLimit: 52428800,
|
||||||
|
AttachmentTotalSizeLimit: 524288000,
|
||||||
|
AttachmentExpiryDuration: 24 * time.Hour,
|
||||||
|
}))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
|
require.Nil(t, a.ChangeTier("ben", "pro"))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", Everyone, "mytopic", false, false))
|
||||||
|
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
|
require.Equal(t, "pro", ben.Tier.Code)
|
||||||
|
require.Equal(t, true, ben.Tier.Paid)
|
||||||
|
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
||||||
|
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
||||||
|
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
|
||||||
|
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
|
||||||
|
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
|
||||||
|
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(benGrants))
|
||||||
|
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
|
||||||
|
|
||||||
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
|
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
|
||||||
|
|
||||||
|
// Switch to admin, this should remove all grants and owned ACL entries
|
||||||
|
require.Nil(t, a.ChangeRole("ben", RoleAdmin))
|
||||||
|
|
||||||
|
benGrants, err = a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(benGrants))
|
||||||
|
|
||||||
|
everyoneGrants, err = a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(everyoneGrants))
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_Token_Valid(t *testing.T) {
|
func TestManager_Token_Valid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
|
|
|
@ -43,27 +43,18 @@ type Prefs struct {
|
||||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierCode is code identifying a user's tier
|
|
||||||
type TierCode string
|
|
||||||
|
|
||||||
// Default tier codes
|
|
||||||
const (
|
|
||||||
TierUnlimited = TierCode("unlimited")
|
|
||||||
TierDefault = TierCode("default")
|
|
||||||
TierNone = TierCode("none")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tier represents a user's account type, including its account limits
|
// Tier represents a user's account type, including its account limits
|
||||||
type Tier struct {
|
type Tier struct {
|
||||||
Code string `json:"name"`
|
Code string
|
||||||
Upgradeable bool `json:"upgradeable"`
|
Name string
|
||||||
MessagesLimit int64 `json:"messages_limit"`
|
Paid bool
|
||||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
MessagesLimit int64
|
||||||
EmailsLimit int64 `json:"emails_limit"`
|
MessagesExpiryDuration time.Duration
|
||||||
ReservationsLimit int64 `json:"reservations_limit"`
|
EmailsLimit int64
|
||||||
AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"`
|
ReservationsLimit int64
|
||||||
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
AttachmentTotalSizeLimit int64
|
||||||
|
AttachmentExpiryDuration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription represents a user's topic subscription
|
// Subscription represents a user's topic subscription
|
||||||
|
@ -185,6 +176,7 @@ var (
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||||
|
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllowedRole returns true if the given role can be used for new users
|
// AllowedRole returns true if the given role can be used for new users
|
||||||
|
@ -198,13 +190,18 @@ func AllowedUsername(username string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedTopic returns true if the given topic name is valid
|
// AllowedTopic returns true if the given topic name is valid
|
||||||
func AllowedTopic(username string) bool {
|
func AllowedTopic(topic string) bool {
|
||||||
return allowedTopicRegex.MatchString(username)
|
return allowedTopicRegex.MatchString(topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||||
func AllowedTopicPattern(username string) bool {
|
func AllowedTopicPattern(topic string) bool {
|
||||||
return allowedTopicPatternRegex.MatchString(username)
|
return allowedTopicPatternRegex.MatchString(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTier returns true if the given tier name is valid
|
||||||
|
func AllowedTier(tier string) bool {
|
||||||
|
return allowedTierRegex.MatchString(tier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error constants used by the package
|
// Error constants used by the package
|
||||||
|
|
11
web/package-lock.json
generated
11
web/package-lock.json
generated
|
@ -14,6 +14,7 @@
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
|
"humanize-duration": "^3.27.3",
|
||||||
"i18next": "^21.6.14",
|
"i18next": "^21.6.14",
|
||||||
"i18next-browser-languagedetector": "^6.1.4",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"i18next-http-backend": "^1.4.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
|
@ -8837,6 +8838,11 @@
|
||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/humanize-duration": {
|
||||||
|
"version": "3.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.3.tgz",
|
||||||
|
"integrity": "sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw=="
|
||||||
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "21.10.0",
|
"version": "21.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
||||||
|
@ -23381,6 +23387,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
|
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
|
||||||
},
|
},
|
||||||
|
"humanize-duration": {
|
||||||
|
"version": "3.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.3.tgz",
|
||||||
|
"integrity": "sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw=="
|
||||||
|
},
|
||||||
"i18next": {
|
"i18next": {
|
||||||
"version": "21.10.0",
|
"version": "21.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
|
"humanize-duration": "^3.27.3",
|
||||||
"i18next": "^21.6.14",
|
"i18next": "^21.6.14",
|
||||||
"i18next-browser-languagedetector": "^6.1.4",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"i18next-http-backend": "^1.4.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
|
|
|
@ -179,17 +179,15 @@
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
||||||
"account_usage_tier_title": "Account type",
|
"account_usage_tier_title": "Account type",
|
||||||
"account_usage_tier_code_default": "Default",
|
"account_usage_tier_admin": "Admin",
|
||||||
"account_usage_tier_code_unlimited": "Unlimited",
|
"account_usage_tier_none": "Basic",
|
||||||
"account_usage_tier_code_none": "None",
|
"account_usage_tier_upgrade_button": "Upgrade to Pro",
|
||||||
"account_usage_tier_code_pro": "Pro",
|
"account_usage_tier_change_button": "Change",
|
||||||
"account_usage_tier_code_business": "Business",
|
|
||||||
"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_reservations_title": "Reserved topics",
|
||||||
"account_usage_attachment_storage_title": "Attachment storage",
|
"account_usage_attachment_storage_title": "Attachment storage",
|
||||||
"account_usage_attachment_storage_subtitle": "{{filesize}} per file",
|
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
|
||||||
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
||||||
"account_delete_title": "Delete account",
|
"account_delete_title": "Delete account",
|
||||||
"account_delete_description": "Permanently delete your account",
|
"account_delete_description": "Permanently delete your account",
|
||||||
|
|
|
@ -24,6 +24,10 @@ import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import {Pref, PrefGroup} from "./Pref";
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
import db from "../app/db";
|
import db from "../app/db";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import humanizeDuration from "humanize-duration";
|
||||||
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
|
@ -166,10 +170,12 @@ const ChangePasswordDialog = (props) => {
|
||||||
const Stats = () => {
|
const Stats = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { account } = useOutletContext();
|
const { account } = useOutletContext();
|
||||||
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
const tierCode = account.tier.code ?? "none";
|
|
||||||
const normalize = (value, max) => Math.min(value / max * 100, 100);
|
const normalize = (value, max) => Math.min(value / max * 100, 100);
|
||||||
const barColor = (remaining, limit) => {
|
const barColor = (remaining, limit) => {
|
||||||
if (account.role === "admin") {
|
if (account.role === "admin") {
|
||||||
|
@ -188,34 +194,63 @@ const Stats = () => {
|
||||||
<PrefGroup>
|
<PrefGroup>
|
||||||
<Pref title={t("account_usage_tier_title")}>
|
<Pref title={t("account_usage_tier_title")}>
|
||||||
<div>
|
<div>
|
||||||
{account.role === "admin"
|
{account.role === "admin" &&
|
||||||
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
|
<>
|
||||||
: t(`account_usage_tier_code_${tierCode}`)}
|
{t("account_usage_tier_admin")}
|
||||||
{config.enable_payments && account.tier.upgradeable &&
|
{" "}{account.tier ? `(with ${account.tier.name} tier)` : `(no tier)`}
|
||||||
<em>{" "}
|
</>
|
||||||
<Link onClick={() => {}}>Upgrade</Link>
|
|
||||||
</em>
|
|
||||||
}
|
}
|
||||||
|
{account.role === "user" && account.tier &&
|
||||||
|
<>{account.tier.name}</>
|
||||||
|
}
|
||||||
|
{account.role === "user" && !account.tier &&
|
||||||
|
t("account_usage_tier_none")
|
||||||
|
}
|
||||||
|
{config.enable_payments && account.role === "user" && (!account.tier || !account.tier.paid) &&
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>}
|
||||||
|
onClick={() => setUpgradeDialogOpen(true)}
|
||||||
|
sx={{ml: 1}}
|
||||||
|
>{t("account_usage_tier_upgrade_button")}</Button>
|
||||||
|
}
|
||||||
|
{config.enable_payments && account.role === "user" && account.tier?.paid &&
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setUpgradeDialogOpen(true)}
|
||||||
|
sx={{ml: 1}}
|
||||||
|
>{t("account_usage_tier_change_button")}</Button>
|
||||||
|
}
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeDialogOpen}
|
||||||
|
onCancel={() => setUpgradeDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Pref>
|
</Pref>
|
||||||
<Pref title={t("account_usage_topics_title")}>
|
{account.role !== "admin" &&
|
||||||
{account.limits.reservations > 0 &&
|
<Pref title={t("account_usage_reservations_title")}>
|
||||||
<>
|
{account.limits.reservations > 0 &&
|
||||||
<div>
|
<>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}</Typography>
|
<Typography variant="body2"
|
||||||
</div>
|
sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
||||||
<LinearProgress
|
<Typography variant="body2"
|
||||||
variant="determinate"
|
sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
|
||||||
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
</div>
|
||||||
color={barColor(account.stats.reservations_remaining, account.limits.reservations)}
|
<LinearProgress
|
||||||
/>
|
variant="determinate"
|
||||||
</>
|
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||||
}
|
color={barColor(account.stats.reservations_remaining, account.limits.reservations)}
|
||||||
{account.limits.reservations === 0 &&
|
/>
|
||||||
<em>No reserved topics for this account</em>
|
</>
|
||||||
}
|
}
|
||||||
</Pref>
|
{account.limits.reservations === 0 &&
|
||||||
|
<em>No reserved topics for this account</em>
|
||||||
|
}
|
||||||
|
</Pref>
|
||||||
|
}
|
||||||
<Pref title={
|
<Pref title={
|
||||||
<>
|
<>
|
||||||
{t("account_usage_messages_title")}
|
{t("account_usage_messages_title")}
|
||||||
|
@ -224,11 +259,11 @@ const Stats = () => {
|
||||||
}>
|
}>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
|
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
|
||||||
<Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
|
||||||
</div>
|
</div>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100}
|
value={account.role === "user" ? normalize(account.stats.messages, account.limits.messages) : 100}
|
||||||
color={account.role === "user" && account.stats.messages_remaining === 0 ? 'error' : 'primary'}
|
color={account.role === "user" && account.stats.messages_remaining === 0 ? 'error' : 'primary'}
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
|
@ -248,14 +283,17 @@ const Stats = () => {
|
||||||
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
|
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
<Pref title={
|
<Pref
|
||||||
<>
|
alignTop
|
||||||
{t("account_usage_attachment_storage_title")}
|
title={t("account_usage_attachment_storage_title")}
|
||||||
{account.role === "user" &&
|
description={t("account_usage_attachment_storage_description", {
|
||||||
<Tooltip title={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}><span><InfoIcon/></span></Tooltip>
|
filesize: formatBytes(account.limits.attachment_file_size),
|
||||||
}
|
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
|
||||||
</>
|
language: i18n.language,
|
||||||
}>
|
fallbacks: ["en"]
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
|
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
|
||||||
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
|
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
|
||||||
|
@ -269,7 +307,7 @@ const Stats = () => {
|
||||||
</PrefGroup>
|
</PrefGroup>
|
||||||
{account.limits.basis === "ip" &&
|
{account.limits.basis === "ip" &&
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
<em>{t("account_usage_basis_ip_description")}</em>
|
{t("account_usage_basis_ip_description")}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {Trans, useTranslation} from "react-i18next";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import CelebrationIcon from '@mui/icons-material/Celebration';
|
import CelebrationIcon from '@mui/icons-material/Celebration';
|
||||||
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
|
||||||
|
@ -99,7 +100,9 @@ const NavList = (props) => {
|
||||||
navigate(routes.account);
|
navigate(routes.account);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUpgradeBanner = config.enable_payments && (!props.account || props.account.tier.upgradeable);
|
const isAdmin = props.account?.role === "admin";
|
||||||
|
const isPaid = props.account?.tier?.paid;
|
||||||
|
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;// && (!props.account || !props.account.tier || !props.account.tier.paid || props.account);
|
||||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||||
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
||||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||||
|
@ -154,32 +157,7 @@ const NavList = (props) => {
|
||||||
<ListItemText primary={t("nav_button_subscribe")}/>
|
<ListItemText primary={t("nav_button_subscribe")}/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
{showUpgradeBanner &&
|
{showUpgradeBanner &&
|
||||||
<Box sx={{
|
<UpgradeBanner/>
|
||||||
position: "fixed",
|
|
||||||
width: `${Navigation.width - 1}px`,
|
|
||||||
bottom: 0,
|
|
||||||
mt: 'auto',
|
|
||||||
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
|
|
||||||
}}>
|
|
||||||
<Divider/>
|
|
||||||
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
|
||||||
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
primary={"Upgrade to ntfy Pro"}
|
|
||||||
secondary={"Reserve topics, more messages & emails, bigger attachments"}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
style: {
|
|
||||||
fontWeight: 500,
|
|
||||||
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
}
|
}
|
||||||
</List>
|
</List>
|
||||||
<SubscribeDialog
|
<SubscribeDialog
|
||||||
|
@ -193,6 +171,41 @@ const NavList = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UpgradeBanner = () => {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
position: "fixed",
|
||||||
|
width: `${Navigation.width - 1}px`,
|
||||||
|
bottom: 0,
|
||||||
|
mt: 'auto',
|
||||||
|
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
|
||||||
|
}}>
|
||||||
|
<Divider/>
|
||||||
|
<ListItemButton onClick={() => setDialogOpen(true)}>
|
||||||
|
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
primary={"Upgrade to ntfy Pro"}
|
||||||
|
secondary={"Reserve topics, more messages & emails, bigger attachments"}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
style: {
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={() => setDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SubscriptionList = (props) => {
|
const SubscriptionList = (props) => {
|
||||||
const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
|
const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
|
||||||
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
|
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
|
||||||
|
|
|
@ -9,6 +9,7 @@ export const PrefGroup = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Pref = (props) => {
|
export const Pref = (props) => {
|
||||||
|
const justifyContent = (props.alignTop) ? "normal" : "center";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="row"
|
role="row"
|
||||||
|
@ -27,7 +28,7 @@ export const Pref = (props) => {
|
||||||
flex: '1 0 40%',
|
flex: '1 0 40%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: justifyContent,
|
||||||
paddingRight: '30px'
|
paddingRight: '30px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -40,7 +41,7 @@ export const Pref = (props) => {
|
||||||
flex: '1 0 calc(60% - 50px)',
|
flex: '1 0 calc(60% - 50px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center'
|
justifyContent: justifyContent
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
44
web/src/components/UpgradeDialog.js
Normal file
44
web/src/components/UpgradeDialog.js
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
|
||||||
|
import theme from "./theme";
|
||||||
|
import api from "../app/Api";
|
||||||
|
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
|
||||||
|
import userManager from "../app/UserManager";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
import poller from "../app/Poller";
|
||||||
|
import DialogFooter from "./DialogFooter";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
|
import {useOutletContext} from "react-router-dom";
|
||||||
|
|
||||||
|
const UpgradeDialog = (props) => {
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
const handleSuccess = async () => {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>Upgrade to Pro</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Content
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
Footer
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpgradeDialog;
|
Loading…
Reference in a new issue