Tiers make sense for admins now

This commit is contained in:
binwiederhier 2023-01-09 15:40:46 -05:00
parent d8032e1c9e
commit 3aba7404fc
18 changed files with 457 additions and 225 deletions

View File

@ -15,6 +15,10 @@ import (
"heckel.io/ntfy/util"
)
const (
tierReset = "-"
)
func init() {
commands = append(commands, cmdUser)
}
@ -110,6 +114,22 @@ user are removed, since they are no longer necessary.
Example:
ntfy user change-role phil admin # Make user phil an admin
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
}
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 {
manager, err := createUserManager(c)
if err != nil {

View File

@ -73,6 +73,7 @@ var (
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
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", ""}
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/"}

View File

@ -36,16 +36,14 @@ import (
/*
TODO
limits & rate limiting:
Limits & rate limiting:
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
reset daily limits for users
reset daily Limits for users
Make sure account endpoints make sense for admins
add logic to set "expires" column (this is gonna be dirty)
UI:
- Align size of message bar and upgrade banner
- flicker of upgrade banner
- JS constants
- useContext for account
@ -53,8 +51,10 @@ import (
- "account topic" sync mechanism
- "mute" setting
- figure out what settings are "web" or "phone"
Delete visitor when tier is changed to refresh rate limiters
Tests:
- visitor with/without user
- Change tier from higher to lower tier (delete reservations)
- Message rate limiting and reset tests
Docs:
- "expires" field in message
Refactor:
@ -528,7 +528,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
return nil, err
}
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)
if err != nil {
@ -545,11 +545,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
if v.user != nil {
m.User = v.user.Name
}
if v.user != nil && v.user.Tier != nil {
m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
} else {
m.Expires = time.Now().Add(s.config.CacheDuration).Unix()
}
m.Expires = time.Now().Add(v.Limits().MessagesExpiryDuration).Unix()
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
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 == "" {
return errHTTPBadRequestAttachmentsDisallowed
}
var attachmentExpiryDuration time.Duration
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()
vinfo, err := v.Info()
if err != nil {
return err
}
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
if m.Time > attachmentExpiry {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
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
}
}
@ -859,8 +849,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
}
limiters := []util.Limiter{
v.BandwidthLimiter(),
util.NewFixedLimiter(stats.AttachmentFileSizeLimit),
util.NewFixedLimiter(stats.AttachmentTotalSizeRemaining),
util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
}
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
if err == util.ErrLimitReached {

View File

@ -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 {
stats, err := v.Info()
info, err := v.Info()
if err != nil {
return err
}
limits, stats := info.Limits, info.Stats
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{
Messages: stats.Messages,
MessagesRemaining: stats.MessagesRemaining,
@ -55,16 +66,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
AttachmentTotalSize: stats.AttachmentTotalSize,
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 {
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 {
response.Tier = &apiAccountTier{
Code: v.user.Tier.Code,
Upgradeable: v.user.Tier.Upgradeable,
}
} 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,
Code: v.user.Tier.Code,
Name: v.user.Tier.Name,
Paid: v.user.Tier.Paid,
}
}
reservations, err := s.userManager.Reservations(v.user.Name)
@ -112,10 +104,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
response.Tier = &apiAccountTier{
Code: string(user.TierNone),
Upgradeable: true,
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this

View File

@ -381,14 +381,14 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
// Create a tier
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "pro",
Upgradeable: false,
Paid: false,
MessagesLimit: 123,
MessagesExpiryDuration: 86400,
MessagesExpiryDuration: 86400 * time.Second,
EmailsLimit: 32,
ReservationsLimit: 2,
AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800,
AttachmentExpiryDuration: 10800 * time.Second,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))

View File

@ -1098,7 +1098,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test",
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.ChangeTier("phil", "test"))
@ -1323,14 +1323,14 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
s := newTestServer(t, c)
// Create tier with certain limits
sevenDaysInSeconds := int64(604800)
sevenDays := time.Duration(604800) * time.Second
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test",
MessagesLimit: 10,
MessagesExpiryDuration: sevenDaysInSeconds,
MessagesExpiryDuration: sevenDays,
AttachmentFileSizeLimit: 50_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.ChangeTier("phil", "test"))
@ -1341,8 +1341,8 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
})
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)
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
require.FileExists(t, file)
@ -1374,7 +1374,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
MessagesLimit: 100,
AttachmentFileSizeLimit: 50_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.ChangeTier("phil", "test"))

View File

@ -236,8 +236,9 @@ type apiAccountTokenResponse struct {
}
type apiAccountTier struct {
Code string `json:"code"`
Upgradeable bool `json:"upgradeable"`
Code string `json:"code"`
Name string `json:"name"`
Paid bool `json:"paid"`
}
type apiAccountLimits struct {

View File

@ -38,29 +38,46 @@ type visitor struct {
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
accountLimiter *rate.Limiter // Rate limiter for account creation
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
}
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
MessagesLimit int64
MessagesRemaining int64
MessagesExpiryDuration int64
Emails int64
EmailsLimit int64
EmailsRemaining int64
Reservations int64
ReservationsLimit int64
ReservationsRemaining int64
AttachmentTotalSize int64
AttachmentTotalSizeLimit 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 {
var messagesLimiter util.Limiter
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
@ -82,13 +99,13 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
return &visitor{
config: conf,
messageCache: messageCache,
userManager: userManager, // May be nil!
userManager: userManager, // May be nil
ip: ip,
user: user,
messages: messages,
emails: emails,
requestLimiter: requestLimiter,
messagesLimiter: messagesLimiter,
messagesLimiter: messagesLimiter, // May be nil
emailsLimiter: emailsLimiter,
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
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) {
v.mu.Lock()
messages := v.messages
emails := v.emails
v.mu.Unlock()
info := &visitorInfo{}
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 attachmentsBytesUsed int64
var err error
if v.user != nil {
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
@ -225,20 +241,26 @@ func (v *visitor) Info() (*visitorInfo, error) {
}
var reservations int64
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 {
return nil, err
}
}
info.Messages = messages
info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages)
info.Emails = emails
info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails)
info.Reservations = reservations
info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations)
info.AttachmentTotalSize = attachmentsBytesUsed
info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize)
return info, nil
limits := v.Limits()
stats := &visitorStats{
Messages: messages,
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
Emails: emails,
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
Reservations: reservations,
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
AttachmentTotalSize: attachmentsBytesUsed,
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
}
return &visitorInfo{
Limits: limits,
Stats: stats,
}, nil
}
func zeroIfNegative(value int64) int64 {

View File

@ -35,6 +35,8 @@ const (
CREATE TABLE IF NOT EXISTS tier (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
name TEXT NOT NULL,
paid INT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
@ -84,13 +86,13 @@ const (
`
selectUserByNameQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.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
LEFT JOIN tier p on p.id = u.tier_id
WHERE user = ?
`
selectUserByTokenQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.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
JOIN user_token t on u.id = t.user_id
LEFT JOIN tier p on p.id = u.tier_id
@ -159,9 +161,17 @@ const (
WHERE (topic = ? OR ? LIKE topic)
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
`
deleteAllAccessQuery = `DELETE FROM user_access`
deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
deleteAllAccessQuery = `DELETE FROM user_access`
deleteUserAccessQuery = `
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 = ?)`
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
@ -180,11 +190,12 @@ const (
`
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 (?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
)
// Schema management queries
@ -528,13 +539,14 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var username, hash, role string
var settings, tierCode sql.NullString
var settings, tierCode, tierName sql.NullString
var paid sql.NullBool
var messages, emails int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
if !rows.Next() {
return nil, ErrNotFound
}
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &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
} else if err := rows.Err(); err != nil {
return nil, err
@ -557,14 +569,15 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
if tierCode.Valid {
user.Tier = &Tier{
Code: tierCode.String,
Upgradeable: false,
Name: tierName.String,
Paid: paid.Bool,
MessagesLimit: messagesLimit.Int64,
MessagesExpiryDuration: messagesExpiryDuration.Int64,
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailsLimit: emailsLimit.Int64,
ReservationsLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: attachmentExpiryDuration.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
}
}
return user, nil
@ -676,7 +689,7 @@ func (a *Manager) ChangeRole(username string, role Role) error {
return err
}
if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
return err
}
}
@ -760,10 +773,19 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error {
_, err := a.db.Exec(deleteAllAccessQuery, username)
return err
} else if topicPattern == "" {
_, err := a.db.Exec(deleteUserAccessQuery, username)
_, err := a.db.Exec(deleteUserAccessQuery, username, username)
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
}
@ -774,7 +796,7 @@ func (a *Manager) DefaultAccess() Permission {
// 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 {
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 nil

View File

@ -256,6 +256,60 @@ func TestManager_ChangeRole(t *testing.T) {
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) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))

View File

@ -43,27 +43,18 @@ type Prefs struct {
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
type Tier struct {
Code string `json:"name"`
Upgradeable bool `json:"upgradeable"`
MessagesLimit int64 `json:"messages_limit"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
EmailsLimit int64 `json:"emails_limit"`
ReservationsLimit int64 `json:"reservations_limit"`
AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"`
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
Code string
Name string
Paid bool
MessagesLimit int64
MessagesExpiryDuration time.Duration
EmailsLimit int64
ReservationsLimit int64
AttachmentFileSizeLimit int64
AttachmentTotalSizeLimit int64
AttachmentExpiryDuration time.Duration
}
// Subscription represents a user's topic subscription
@ -185,6 +176,7 @@ var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
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
@ -198,13 +190,18 @@ func AllowedUsername(username string) bool {
}
// AllowedTopic returns true if the given topic name is valid
func AllowedTopic(username string) bool {
return allowedTopicRegex.MatchString(username)
func AllowedTopic(topic string) bool {
return allowedTopicRegex.MatchString(topic)
}
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
func AllowedTopicPattern(username string) bool {
return allowedTopicPatternRegex.MatchString(username)
func AllowedTopicPattern(topic string) bool {
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

11
web/package-lock.json generated
View File

@ -14,6 +14,7 @@
"@mui/material": "latest",
"dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1",
"humanize-duration": "^3.27.3",
"i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0",
@ -8837,6 +8838,11 @@
"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": {
"version": "21.10.0",
"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",
"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": {
"version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",

View File

@ -15,6 +15,7 @@
"@mui/material": "latest",
"dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1",
"humanize-duration": "^3.27.3",
"i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0",

View File

@ -179,17 +179,15 @@
"account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_tier_title": "Account type",
"account_usage_tier_code_default": "Default",
"account_usage_tier_code_unlimited": "Unlimited",
"account_usage_tier_code_none": "None",
"account_usage_tier_code_pro": "Pro",
"account_usage_tier_code_business": "Business",
"account_usage_tier_code_business_plus": "Business Plus",
"account_usage_tier_admin": "Admin",
"account_usage_tier_none": "Basic",
"account_usage_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_change_button": "Change",
"account_usage_messages_title": "Published messages",
"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_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_delete_title": "Delete account",
"account_delete_description": "Permanently delete your account",

View File

@ -24,6 +24,10 @@ import accountApi, {UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref";
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 = () => {
if (!session.exists()) {
@ -166,10 +170,12 @@ const ChangePasswordDialog = (props) => {
const Stats = () => {
const { t } = useTranslation();
const { account } = useOutletContext();
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
if (!account) {
return <></>;
}
const tierCode = account.tier.code ?? "none";
const normalize = (value, max) => Math.min(value / max * 100, 100);
const barColor = (remaining, limit) => {
if (account.role === "admin") {
@ -188,34 +194,63 @@ const Stats = () => {
<PrefGroup>
<Pref title={t("account_usage_tier_title")}>
<div>
{account.role === "admin"
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
: t(`account_usage_tier_code_${tierCode}`)}
{config.enable_payments && account.tier.upgradeable &&
<em>{" "}
<Link onClick={() => {}}>Upgrade</Link>
</em>
{account.role === "admin" &&
<>
{t("account_usage_tier_admin")}
{" "}{account.tier ? `(with ${account.tier.name} tier)` : `(no tier)`}
</>
}
{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>
</Pref>
<Pref title={t("account_usage_topics_title")}>
{account.limits.reservations > 0 &&
<>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.limits.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.role !== "admin" &&
<Pref title={t("account_usage_reservations_title")}>
{account.limits.reservations > 0 &&
<>
<div>
<Typography variant="body2"
sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2"
sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.limits.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>
}
<Pref title={
<>
{t("account_usage_messages_title")}
@ -224,11 +259,11 @@ const Stats = () => {
}>
<div>
<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>
<LinearProgress
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'}
/>
</Pref>
@ -248,14 +283,17 @@ const Stats = () => {
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
/>
</Pref>
<Pref title={
<>
{t("account_usage_attachment_storage_title")}
{account.role === "user" &&
<Tooltip title={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}><span><InfoIcon/></span></Tooltip>
}
</>
}>
<Pref
alignTop
title={t("account_usage_attachment_storage_title")}
description={t("account_usage_attachment_storage_description", {
filesize: formatBytes(account.limits.attachment_file_size),
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
language: i18n.language,
fallbacks: ["en"]
})
})}
>
<div>
<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>
@ -269,7 +307,7 @@ const Stats = () => {
</PrefGroup>
{account.limits.basis === "ip" &&
<Typography variant="body1">
<em>{t("account_usage_basis_ip_description")}</em>
{t("account_usage_basis_ip_description")}
</Typography>
}
</Card>

View File

@ -29,6 +29,7 @@ import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session";
import accountApi from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration';
import UpgradeDialog from "./UpgradeDialog";
const navWidth = 280;
@ -99,7 +100,9 @@ const NavList = (props) => {
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 showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
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")}/>
</ListItemButton>
{showUpgradeBanner &&
<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={() => 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>
<UpgradeBanner/>
}
</List>
<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 sortedSubscriptions = props.subscriptions.sort( (a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;

View File

@ -9,6 +9,7 @@ export const PrefGroup = (props) => {
};
export const Pref = (props) => {
const justifyContent = (props.alignTop) ? "normal" : "center";
return (
<div
role="row"
@ -27,7 +28,7 @@ export const Pref = (props) => {
flex: '1 0 40%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
justifyContent: justifyContent,
paddingRight: '30px'
}}
>
@ -40,7 +41,7 @@ export const Pref = (props) => {
flex: '1 0 calc(60% - 50px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
justifyContent: justifyContent
}}
>
{props.children}

View 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;