Self-review, round 2

pull/526/head
binwiederhier 2023-02-09 15:24:12 -05:00
parent bcb22d8d4c
commit e6bb5f484c
24 changed files with 288 additions and 183 deletions

View File

@ -61,7 +61,7 @@ var cmdTier = &cli.Command{
Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or
make it possible for users to reserve topics. make it possible for users to reserve topics.
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. file server.yml. The command only works if 'auth-file' is properly defined.
Examples: Examples:
@ -102,7 +102,7 @@ Examples:
After updating a tier, you may have to restart the ntfy server to apply them After updating a tier, you may have to restart the ntfy server to apply them
to all visitors. to all visitors.
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. file server.yml. The command only works if 'auth-file' is properly defined.
Examples: Examples:
@ -124,7 +124,7 @@ Examples:
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier" You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
to remove or switch their tier first. to remove or switch their tier first.
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. file server.yml. The command only works if 'auth-file' is properly defined.
Example: Example:
@ -138,7 +138,7 @@ Example:
Action: execTierList, Action: execTierList,
Description: `Shows a list of all configured tiers. Description: `Shows a list of all configured tiers.
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. file server.yml. The command only works if 'auth-file' is properly defined.
`, `,
}, },

View File

@ -27,8 +27,26 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
require.Contains(t, stderr.String(), "- Message limit: 1234") require.Contains(t, stderr.String(), "- Message limit: 1234")
app, _, _, stderr = newTestApp() app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "change", "--message-limit", "999", "pro")) require.Nil(t, runTierCommand(app, conf, "change",
"--message-limit=999",
"--message-expiry-duration=99h",
"--email-limit=91",
"--reservation-limit=98",
"--attachment-file-size-limit=100m",
"--attachment-expiry-duration=7h",
"--attachment-total-size-limit=10G",
"--attachment-bandwidth-limit=100G",
"--stripe-price-id=price_991",
"pro",
))
require.Contains(t, stderr.String(), "- Message limit: 999") require.Contains(t, stderr.String(), "- Message limit: 999")
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
require.Contains(t, stderr.String(), "- Email limit: 91")
require.Contains(t, stderr.String(), "- Reservation limit: 98")
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
require.Contains(t, stderr.String(), "- Stripe price: price_991")
app, _, _, stderr = newTestApp() app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro")) require.Nil(t, runTierCommand(app, conf, "remove", "pro"))

View File

@ -42,6 +42,9 @@ User access tokens can be used to publish, subscribe, or perform any other user-
Tokens have full access, and can perform any task a user can do. They are meant to be used to Tokens have full access, and can perform any task a user can do. They are meant to be used to
avoid spreading the password to various places. avoid spreading the password to various places.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples: Examples:
ntfy token add phil # Create token for user phil which never expires ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
@ -66,7 +69,7 @@ Example:
Action: execTokenList, Action: execTokenList,
Description: `Shows a list of all tokens. Description: `Shows a list of all tokens.
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.`, file server.yml. The command only works if 'auth-file' is properly defined.`,
}, },
}, },

View File

@ -141,7 +141,7 @@ Example:
This command is an alias to calling 'ntfy access' (display access control list). This command is an alias to calling 'ntfy access' (display access control list).
This is a server-only command. It directly reads from the user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. file server.yml. The command only works if 'auth-file' is properly defined.
`, `,
}, },

View File

@ -13,6 +13,7 @@ import (
const ( const (
tagField = "tag" tagField = "tag"
errorField = "error" errorField = "error"
timeTakenField = "time_taken_ms"
exitCodeField = "exit_code" exitCodeField = "exit_code"
timestampFormat = "2006-01-02T15:04:05.999Z07:00" timestampFormat = "2006-01-02T15:04:05.999Z07:00"
) )
@ -80,6 +81,13 @@ func (e *Event) Time(t time.Time) *Event {
return e return e
} }
// Timing runs f and records the time if took to execute it in "time_taken_ms"
func (e *Event) Timing(f func()) *Event {
start := time.Now()
f()
return e.Field(timeTakenField, time.Since(start).Milliseconds())
}
// Err adds an "error" field to the log event // Err adds an "error" field to the log event
func (e *Event) Err(err error) *Event { func (e *Event) Err(err error) *Event {
if err == nil { if err == nil {

View File

@ -78,6 +78,11 @@ func Time(time time.Time) *Event {
return newEvent().Time(time) return newEvent().Time(time)
} }
// Timing runs f and records the time if took to execute it in "time_taken_ms"
func Timing(f func()) *Event {
return newEvent().Timing(f)
}
// CurrentLevel returns the current log level // CurrentLevel returns the current log level
func CurrentLevel() Level { func CurrentLevel() Level {
mu.Lock() mu.Lock()

View File

@ -2,6 +2,7 @@ package log
import ( import (
"bytes" "bytes"
"encoding/json"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"os" "os"
"testing" "testing"
@ -131,6 +132,25 @@ func TestLog_NoAllocIfNotPrinted(t *testing.T) {
require.Equal(t, expected, out.String()) require.Equal(t, expected, out.String())
} }
func TestLog_Timing(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
Timing(func() { time.Sleep(300 * time.Millisecond) }).
Time(time.Unix(12, 0).UTC()).
Info("A thing that takes a while")
var ev struct {
TimeTakenMs int64 `json:"time_taken_ms"`
}
require.Nil(t, json.Unmarshal(out.Bytes(), &ev))
require.True(t, ev.TimeTakenMs >= 300)
require.Contains(t, out.String(), `{"time":"1970-01-01T00:00:12Z","level":"INFO","message":"A thing that takes a while","time_taken_ms":`)
}
type fakeError struct { type fakeError struct {
Code int Code int
Message string Message string

View File

@ -164,6 +164,7 @@ func NewConfig() *Config {
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
KeepaliveInterval: DefaultKeepaliveInterval, KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval, ManagerInterval: DefaultManagerInterval,
DisallowedTopics: DefaultDisallowedTopics,
WebRootIsApp: false, WebRootIsApp: false,
DelayedSenderInterval: DefaultDelayedSenderInterval, DelayedSenderInterval: DefaultDelayedSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,

View File

@ -51,6 +51,8 @@ const (
CREATE INDEX IF NOT EXISTS idx_time ON messages (time); CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
COMMIT; COMMIT;
` `
@ -215,6 +217,8 @@ const (
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
` `
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
@ -883,8 +887,5 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil { if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
return err return err
} }
if err := tx.Commit(); err != nil { return tx.Commit()
return err
}
return nil // Update this when a new version is added
} }

View File

@ -37,12 +37,13 @@ import (
- HIGH Docs - HIGH Docs
- tiers - tiers
- api - api
- tokens
- HIGH Self-review - HIGH Self-review
- MEDIUM: Test for expiring messages after reservation removal - MEDIUM: Test for expiring messages after reservation removal
- MEDIUM: uploading attachments leads to 404 -- race - MEDIUM: uploading attachments leads to 404 -- race
- MEDIUM: Do not call tiers endoint when payments is not enabled
- MEDIUM: Test new token endpoints & never-expiring token - MEDIUM: Test new token endpoints & never-expiring token
- LOW: UI: Flickering upgrade banner when logging in - LOW: UI: Flickering upgrade banner when logging in
- LOW: Menu item -> popup click should not open page
*/ */
@ -140,6 +141,7 @@ const (
const ( const (
tagStartup = "startup" tagStartup = "startup"
tagPublish = "publish" tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase" tagFirebase = "firebase"
tagEmail = "email" // Send email tagEmail = "email" // Send email
tagSMTP = "smtp" // Receive email tagSMTP = "smtp" // Receive email
@ -649,7 +651,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
} }
u := v.User() u := v.User()
if s.userManager != nil && u != nil && u.Tier != nil { if s.userManager != nil && u != nil && u.Tier != nil {
go s.userManager.EnqueueStats(u.ID, v.Stats()) go s.userManager.EnqueueUserStats(u.ID, v.Stats())
} }
s.mu.Lock() s.mu.Lock()
s.messages++ s.messages++
@ -956,8 +958,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
} }
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error { func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
logvr(v, r).Debug("HTTP stream connection opened") logvr(v, r).Tag(tagSubscribe).Debug("HTTP stream connection opened")
defer logvr(v, r).Debug("HTTP stream connection closed") defer logvr(v, r).Tag(tagSubscribe).Debug("HTTP stream connection closed")
if !v.SubscriptionAllowed() { if !v.SubscriptionAllowed() {
return errHTTPTooManyRequestsLimitSubscriptions return errHTTPTooManyRequestsLimitSubscriptions
} }
@ -1025,7 +1027,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
case <-r.Context().Done(): case <-r.Context().Done():
return nil return nil
case <-time.After(s.config.KeepaliveInterval): case <-time.After(s.config.KeepaliveInterval):
logvr(v, r).Trace("Sending keepalive message") logvr(v, r).Tag(tagSubscribe).Trace("Sending keepalive message")
v.Keepalive() v.Keepalive()
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
return err return err
@ -1283,70 +1285,86 @@ func (s *Server) topicFromID(id string) (*topic, error) {
} }
func (s *Server) execManager() { func (s *Server) execManager() {
log.Tag(tagManager).Debug("Starting manager")
defer log.Tag(tagManager).Debug("Finished manager")
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this // WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function. // there is no mutex for the entire function.
// Expire visitors from rate visitors map // Expire visitors from rate visitors map
s.mu.Lock()
staleVisitors := 0 staleVisitors := 0
for ip, v := range s.visitors { log.
if v.Stale() { Tag(tagManager).
log.Tag(tagManager).With(v).Trace("Deleting stale visitor") Timing(func() {
delete(s.visitors, ip) s.mu.Lock()
staleVisitors++ defer s.mu.Unlock()
} for ip, v := range s.visitors {
} if v.Stale() {
s.mu.Unlock() log.Tag(tagManager).With(v).Trace("Deleting stale visitor")
log.Tag(tagManager).Field("stale_visitors", staleVisitors).Debug("Deleted %d stale visitor(s)", staleVisitors) delete(s.visitors, ip)
staleVisitors++
}
}
}).
Field("stale_visitors", staleVisitors).
Debug("Deleted %d stale visitor(s)", staleVisitors)
// Delete expired user tokens and users // Delete expired user tokens and users
if s.userManager != nil { if s.userManager != nil {
if err := s.userManager.RemoveExpiredTokens(); err != nil { log.
log.Tag(tagManager).Err(err).Warn("Error expiring user tokens") Tag(tagManager).
} Timing(func() {
if err := s.userManager.RemoveDeletedUsers(); err != nil { if err := s.userManager.RemoveExpiredTokens(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users") log.Tag(tagManager).Err(err).Warn("Error expiring user tokens")
} }
if err := s.userManager.RemoveDeletedUsers(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
}
}).
Debug("Removed expired tokens and users")
} }
// Delete expired attachments // Delete expired attachments
if s.fileCache != nil { if s.fileCache != nil {
ids, err := s.messageCache.AttachmentsExpired() log.
if err != nil { Tag(tagManager).
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") Timing(func() {
} else if len(ids) > 0 { ids, err := s.messageCache.AttachmentsExpired()
if log.Tag(tagManager).IsDebug() { if err != nil {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} } else if len(ids) > 0 {
if err := s.fileCache.Remove(ids...); err != nil { if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments") log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
} }
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
} }
} else { if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Debug("No expired attachments to delete") log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
} }
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
} }
// Prune messages // Prune messages
log.Tag(tagManager).Debug("Manager: Pruning messages") log.
expiredMessageIDs, err := s.messageCache.MessagesExpired() Tag(tagManager).
if err != nil { Timing(func() {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") expiredMessageIDs, err := s.messageCache.MessagesExpired()
} else if len(expiredMessageIDs) > 0 { if err != nil {
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil { log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") } else if len(expiredMessageIDs) > 0 {
} if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil { log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") }
} if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
} else { log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
log.Tag(tagManager).Debug("No expired messages to delete") }
} } else {
log.Tag(tagManager).Debug("No expired messages to delete")
}
}).
Debug("Pruned messages")
// Message count per topic // Message count per topic
var messagesCached int var messagesCached int
@ -1360,20 +1378,26 @@ func (s *Server) execManager() {
} }
// Remove subscriptions without subscribers // Remove subscriptions without subscribers
s.mu.Lock() var emptyTopics, subscribers int
var subscribers int log.
for _, t := range s.topics { Tag(tagManager).
subs := t.SubscribersCount() Timing(func() {
log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs) s.mu.Lock()
msgs, exists := messageCounts[t.ID] defer s.mu.Unlock()
if subs == 0 && (!exists || msgs == 0) { for _, t := range s.topics {
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID) subs := t.SubscribersCount()
delete(s.topics, t.ID) log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs)
continue msgs, exists := messageCounts[t.ID]
} if subs == 0 && (!exists || msgs == 0) {
subscribers += subs log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
} emptyTopics++
s.mu.Unlock() delete(s.topics, t.ID)
continue
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)
// Mail stats // Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64 var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
@ -1407,6 +1431,10 @@ func (s *Server) execManager() {
Info("Server stats") Info("Server stats")
} }
func (s *Server) expireVisitors() {
}
func (s *Server) runSMTPServer() error { func (s *Server) runSMTPServer() error {
s.smtpServerBackend = newMailBackend(s.config, s.handle) s.smtpServerBackend = newMailBackend(s.config, s.handle)
s.smtpServer = smtp.NewServer(s.smtpServerBackend) s.smtpServer = smtp.NewServer(s.smtpServerBackend)
@ -1424,7 +1452,10 @@ func (s *Server) runManager() {
for { for {
select { select {
case <-time.After(s.config.ManagerInterval): case <-time.After(s.config.ManagerInterval):
s.execManager() log.
Tag(tagManager).
Timing(s.execManager).
Debug("Manager finished")
case <-s.closeChan: case <-s.closeChan:
return return
} }

View File

@ -314,7 +314,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
} }
} }
logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name) logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name)
if err := s.userManager.ChangeSettings(u); err != nil { if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
@ -338,7 +338,8 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
} }
if newSubscription.ID == "" { if newSubscription.ID == "" {
newSubscription.ID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) newSubscription.ID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
u.Prefs.Subscriptions = append(u.Prefs.Subscriptions, newSubscription) prefs := u.Prefs
prefs.Subscriptions = append(prefs.Subscriptions, newSubscription)
logvr(v, r). logvr(v, r).
Tag(tagAccount). Tag(tagAccount).
Fields(log.Context{ Fields(log.Context{
@ -346,7 +347,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
"topic": newSubscription.Topic, "topic": newSubscription.Topic,
}). }).
Debug("Adding subscription for user %s", u.Name) Debug("Adding subscription for user %s", u.Name)
if err := s.userManager.ChangeSettings(u); err != nil { if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
} }
@ -367,8 +368,9 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
if u.Prefs == nil || u.Prefs.Subscriptions == nil { if u.Prefs == nil || u.Prefs.Subscriptions == nil {
return errHTTPNotFound return errHTTPNotFound
} }
prefs := u.Prefs
var subscription *user.Subscription var subscription *user.Subscription
for _, sub := range u.Prefs.Subscriptions { for _, sub := range prefs.Subscriptions {
if sub.ID == subscriptionID { if sub.ID == subscriptionID {
sub.DisplayName = updatedSubscription.DisplayName sub.DisplayName = updatedSubscription.DisplayName
subscription = sub subscription = sub
@ -386,7 +388,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
"display_name": subscription.DisplayName, "display_name": subscription.DisplayName,
}). }).
Debug("Changing subscription for user %s", u.Name) Debug("Changing subscription for user %s", u.Name)
if err := s.userManager.ChangeSettings(u); err != nil { if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
return s.writeJSON(w, subscription) return s.writeJSON(w, subscription)
@ -417,8 +419,9 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
} }
} }
if len(newSubscriptions) < len(u.Prefs.Subscriptions) { if len(newSubscriptions) < len(u.Prefs.Subscriptions) {
u.Prefs.Subscriptions = newSubscriptions prefs := u.Prefs
if err := s.userManager.ChangeSettings(u); err != nil { prefs.Subscriptions = newSubscriptions
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
} }

View File

@ -724,5 +724,5 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
u, err = s.userManager.User("phil") u, err = s.userManager.User("phil")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages) // v.EnqueueStats had run! require.Equal(t, int64(0), u.Stats.Messages) // v.EnqueueUserStats had run!
} }

View File

@ -938,7 +938,7 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
require.Nil(t, err) require.Nil(t, err)
s.userManager.EnqueueStats(u.ID, &user.Stats{ s.userManager.EnqueueUserStats(u.ID, &user.Stats{
Messages: 123456, Messages: 123456,
Emails: 999, Emails: 999,
}) })

View File

@ -88,7 +88,7 @@ func (t *topic) CancelSubscribers(exceptUserID string) {
defer t.mu.Unlock() defer t.mu.Unlock()
for _, s := range t.subscribers { for _, s := range t.subscribers {
if s.userID != exceptUserID { if s.userID != exceptUserID {
log.Field("topic", t.ID).Trace("Canceling subscriber %s", s.userID) log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
s.cancel() s.cancel()
} }
} }

View File

@ -27,7 +27,7 @@ const (
) )
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
// values (token bucket). // values (token bucket). This is only used to increase the values in server.yml, never decrease them.
// //
// Example: Assuming a user.Tier's MessageLimit is 10,000: // Example: Assuming a user.Tier's MessageLimit is 10,000:
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max) // - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
@ -59,7 +59,7 @@ type visitor struct {
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
authLimiter *rate.Limiter // Limiter for incorrect login attempts authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
firebase time.Time // Next allowed Firebase message firebase time.Time // Next allowed Firebase message
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
mu sync.Mutex mu sync.Mutex
@ -360,7 +360,7 @@ func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool
v.authLimiter = nil // Users are already logged in, no need to limit requests v.authLimiter = nil // Users are already logged in, no need to limit requests
} }
if enqueueUpdate && v.user != nil { if enqueueUpdate && v.user != nil {
go v.userManager.EnqueueStats(v.user.ID, &user.Stats{ go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
Messages: messages, Messages: messages,
Emails: emails, Emails: emails,
}) })

View File

@ -1,3 +1,4 @@
// Package user deals with authentication and authorization against topics
package user package user
import ( import (
@ -28,7 +29,7 @@ const (
tokenPrefix = "tk_" tokenPrefix = "tk_"
tokenLength = 32 tokenLength = 32
tokenMaxCount = 20 // Only keep this many tokens in the table per user tokenMaxCount = 20 // Only keep this many tokens in the table per user
tagManager = "user_manager" tag = "user_manager"
) )
// Default constants that may be overridden by configs // Default constants that may be overridden by configs
@ -166,7 +167,7 @@ const (
` `
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
@ -305,6 +306,12 @@ const (
` `
) )
var (
migrations = map[int]func(db *sql.DB) error{
1: migrateFrom1,
}
)
// Manager is an implementation of Manager. It stores users and access control list // Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database. // in a SQLite database.
type Manager struct { type Manager struct {
@ -350,15 +357,15 @@ func (a *Manager) Authenticate(username, password string) (*User, error) {
} }
user, err := a.User(username) user, err := a.User(username)
if err != nil { if err != nil {
log.Tag(tagManager).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if user.Deleted { } else if user.Deleted {
log.Tag(tagManager).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted") log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { } else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Tag(tagManager).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)") log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)")
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
return user, nil return user, nil
@ -372,7 +379,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
} }
user, err := a.userByToken(token) user, err := a.userByToken(token)
if err != nil { if err != nil {
log.Tag(tagManager).Field("token", token).Err(err).Trace("Authentication of token failed") log.Tag(tag).Field("token", token).Err(err).Trace("Authentication of token failed")
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
user.Token = token user.Token = token
@ -532,12 +539,12 @@ func (a *Manager) RemoveDeletedUsers() error {
} }
// ChangeSettings persists the user settings // ChangeSettings persists the user settings
func (a *Manager) ChangeSettings(user *User) error { func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error {
prefs, err := json.Marshal(user.Prefs) b, err := json.Marshal(prefs)
if err != nil { if err != nil {
return err return err
} }
if _, err := a.db.Exec(updateUserPrefsQuery, string(prefs), user.Name); err != nil { if _, err := a.db.Exec(updateUserPrefsQuery, string(b), userID); err != nil {
return err return err
} }
return nil return nil
@ -554,9 +561,9 @@ func (a *Manager) ResetStats() error {
return nil return nil
} }
// EnqueueStats adds the user to a queue which writes out user stats (messages, emails, ..) in // EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in
// batches at a regular interval // batches at a regular interval
func (a *Manager) EnqueueStats(userID string, stats *Stats) { func (a *Manager) EnqueueUserStats(userID string, stats *Stats) {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
a.statsQueue[userID] = stats a.statsQueue[userID] = stats
@ -574,10 +581,10 @@ func (a *Manager) asyncQueueWriter(interval time.Duration) {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
for range ticker.C { for range ticker.C {
if err := a.writeUserStatsQueue(); err != nil { if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tagManager).Err(err).Warn("Writing user stats queue failed") log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
} }
if err := a.writeTokenUpdateQueue(); err != nil { if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tagManager).Err(err).Warn("Writing token update queue failed") log.Tag(tag).Err(err).Warn("Writing token update queue failed")
} }
} }
} }
@ -586,7 +593,7 @@ func (a *Manager) writeUserStatsQueue() error {
a.mu.Lock() a.mu.Lock()
if len(a.statsQueue) == 0 { if len(a.statsQueue) == 0 {
a.mu.Unlock() a.mu.Unlock()
log.Tag(tagManager).Trace("No user stats updates to commit") log.Tag(tag).Trace("No user stats updates to commit")
return nil return nil
} }
statsQueue := a.statsQueue statsQueue := a.statsQueue
@ -597,10 +604,10 @@ func (a *Manager) writeUserStatsQueue() error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
log.Tag(tagManager).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue))
for userID, update := range statsQueue { for userID, update := range statsQueue {
log. log.
Tag(tagManager). Tag(tag).
Fields(log.Context{ Fields(log.Context{
"user_id": userID, "user_id": userID,
"messages_count": update.Messages, "messages_count": update.Messages,
@ -618,7 +625,7 @@ func (a *Manager) writeTokenUpdateQueue() error {
a.mu.Lock() a.mu.Lock()
if len(a.tokenQueue) == 0 { if len(a.tokenQueue) == 0 {
a.mu.Unlock() a.mu.Unlock()
log.Tag(tagManager).Trace("No token updates to commit") log.Tag(tag).Trace("No token updates to commit")
return nil return nil
} }
tokenQueue := a.tokenQueue tokenQueue := a.tokenQueue
@ -629,9 +636,9 @@ func (a *Manager) writeTokenUpdateQueue() error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
log.Tag(tagManager).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue))
for tokenID, update := range tokenQueue { for tokenID, update := range tokenQueue {
log.Tag(tagManager).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix())
if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); err != nil { if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); err != nil {
return err return err
} }
@ -718,7 +725,7 @@ func (a *Manager) MarkUserRemoved(user *User) error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := a.db.Exec(deleteUserAccessQuery, user.Name, user.Name); err != nil { if _, err := tx.Exec(deleteUserAccessQuery, user.Name, user.Name); err != nil {
return err return err
} }
if _, err := tx.Exec(deleteAllTokenQuery, user.ID); err != nil { if _, err := tx.Exec(deleteAllTokenQuery, user.ID); err != nil {
@ -1012,7 +1019,6 @@ func (a *Manager) checkReservationsLimit(username string, reservationsLimit int6
// CheckAllowAccess tests if a user may create an access control entry for the given topic. // CheckAllowAccess tests if a user may create an access control entry for the given topic.
// If there are any ACL entries that are not owned by the user, an error is returned. // If there are any ACL entries that are not owned by the user, an error is returned.
// FIXME is this the same as HasReservation?
func (a *Manager) CheckAllowAccess(username string, topic string) error { func (a *Manager) CheckAllowAccess(username string, topic string) error {
if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) {
return ErrInvalidArgument return ErrInvalidArgument
@ -1275,10 +1281,18 @@ func setupDB(db *sql.DB) error {
// Do migrations // Do migrations
if schemaVersion == currentSchemaVersion { if schemaVersion == currentSchemaVersion {
return nil return nil
} else if schemaVersion == 1 { } else if schemaVersion > currentSchemaVersion {
return migrateFrom1(db) return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
} }
return fmt.Errorf("unexpected schema version found: %d", schemaVersion) for i := schemaVersion; i < currentSchemaVersion; i++ {
fn, ok := migrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
} }
func setupNewDB(db *sql.DB) error { func setupNewDB(db *sql.DB) error {
@ -1292,7 +1306,7 @@ func setupNewDB(db *sql.DB) error {
} }
func migrateFrom1(db *sql.DB) error { func migrateFrom1(db *sql.DB) error {
log.Tag(tagManager).Info("Migrating user database schema: from 1 to 2") log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return err
@ -1339,7 +1353,7 @@ func migrateFrom1(db *sql.DB) error {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return err return err
} }
return nil // Update this when a new version is added return nil
} }
func nullString(s string) sql.NullString { func nullString(s string) sql.NullString {

View File

@ -562,7 +562,7 @@ func TestManager_EnqueueStats(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages) require.Equal(t, int64(0), u.Stats.Messages)
require.Equal(t, int64(0), u.Stats.Emails) require.Equal(t, int64(0), u.Stats.Emails)
a.EnqueueStats(u.ID, &Stats{ a.EnqueueUserStats(u.ID, &Stats{
Messages: 11, Messages: 11,
Emails: 2, Emails: 2,
}) })
@ -595,7 +595,7 @@ func TestManager_ChangeSettings(t *testing.T) {
require.Nil(t, u.Prefs.Language) require.Nil(t, u.Prefs.Language)
// Save with new settings // Save with new settings
u.Prefs = &Prefs{ prefs := &Prefs{
Language: util.String("de"), Language: util.String("de"),
Notification: &NotificationPrefs{ Notification: &NotificationPrefs{
Sound: util.String("ding"), Sound: util.String("ding"),
@ -610,7 +610,7 @@ func TestManager_ChangeSettings(t *testing.T) {
}, },
}, },
} }
require.Nil(t, a.ChangeSettings(u)) require.Nil(t, a.ChangeSettings(u.ID, prefs))
// Read again // Read again
u, err = a.User("ben") u, err = a.User("ben")

View File

@ -1,4 +1,3 @@
// Package user deals with authentication and authorization against topics
package user package user
import ( import (

View File

@ -234,7 +234,7 @@ func FormatSize(b int64) string {
div *= unit div *= unit
exp++ exp++
} }
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
} }
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the

View File

@ -176,24 +176,25 @@
"account_basics_password_dialog_current_password_label": "Current password", "account_basics_password_dialog_current_password_label": "Current password",
"account_basics_password_dialog_new_password_label": "New password", "account_basics_password_dialog_new_password_label": "New password",
"account_basics_password_dialog_confirm_password_label": "Confirm password", "account_basics_password_dialog_confirm_password_label": "Confirm password",
"account_basics_password_dialog_button_cancel": "Cancel",
"account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_button_submit": "Change password",
"account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_password_dialog_current_password_incorrect": "Password incorrect",
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_tier_title": "Account type", "account_basics_tier_title": "Account type",
"account_usage_tier_description": "Your account's power level", "account_basics_tier_description": "Your account's power level",
"account_usage_tier_admin": "Admin", "account_basics_tier_admin": "Admin",
"account_usage_tier_basic": "Basic", "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
"account_usage_tier_free": "Free", "account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_usage_tier_upgrade_button": "Upgrade to Pro", "account_basics_tier_basic": "Basic",
"account_usage_tier_change_button": "Change", "account_basics_tier_free": "Free",
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew", "account_basics_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.", "account_basics_tier_change_button": "Change",
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.", "account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
"account_usage_manage_billing_button": "Manage billing", "account_basics_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
"account_basics_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
"account_basics_tier_manage_billing_button": "Manage billing",
"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_reservations_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
@ -204,7 +205,7 @@
"account_usage_cannot_create_portal_session": "Unable to open billing portal", "account_usage_cannot_create_portal_session": "Unable to open billing portal",
"account_delete_title": "Delete account", "account_delete_title": "Delete account",
"account_delete_description": "Permanently delete your account", "account_delete_description": "Permanently delete your account",
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please confirm with your password in the box below.", "account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. After deletion, your username will be unavailable for 7 days. If you really want to proceed, please confirm with your password in the box below.",
"account_delete_dialog_label": "Password", "account_delete_dialog_label": "Password",
"account_delete_dialog_button_cancel": "Cancel", "account_delete_dialog_button_cancel": "Cancel",
"account_delete_dialog_button_submit": "Permanently delete account", "account_delete_dialog_button_submit": "Permanently delete account",

View File

@ -27,6 +27,7 @@ class AccountApi {
constructor() { constructor() {
this.timer = null; this.timer = null;
this.listener = null; // Fired when account is fetched from remote this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached
} }
registerListener(listener) { registerListener(listener) {
@ -148,11 +149,7 @@ class AccountApi {
console.log(`[AccountApi] Extending user access token ${url}`); console.log(`[AccountApi] Extending user access token ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token())
body: JSON.stringify({
token: session.token(),
expires: Math.floor(Date.now() / 1000) + 6220800 // FIXME
})
}); });
} }
@ -239,10 +236,14 @@ class AccountApi {
} }
async billingTiers() { async billingTiers() {
if (this.tiers) {
return this.tiers;
}
const url = tiersUrl(config.base_url); const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`); console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetchOrThrow(url); // No auth needed! const response = await fetchOrThrow(url); // No auth needed!
return await response.json(); // May throw SyntaxError this.tiers = await response.json(); // May throw SyntaxError
return this.tiers;
} }
async createBillingSubscription(tier) { async createBillingSubscription(tier) {

View File

@ -198,7 +198,7 @@ const ChangePasswordDialog = (props) => {
/> />
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("account_basics_password_dialog_button_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button <Button
onClick={handleDialogSubmit} onClick={handleDialogSubmit}
disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword} disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}
@ -242,10 +242,10 @@ const AccountType = () => {
let accountType; let accountType;
if (account.role === Role.ADMIN) { if (account.role === Role.ADMIN) {
const tierSuffix = (account.tier) ? `(with ${account.tier.name} tier)` : `(no tier)`; const tierSuffix = (account.tier) ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier");
accountType = `${t("account_usage_tier_admin")} ${tierSuffix}`; accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
} else if (!account.tier) { } else if (!account.tier) {
accountType = (config.enable_payments) ? t("account_usage_tier_free") : t("account_usage_tier_basic"); accountType = (config.enable_payments) ? t("account_basics_tier_free") : t("account_basics_tier_basic");
} else { } else {
accountType = account.tier.name; accountType = account.tier.name;
} }
@ -253,13 +253,13 @@ const AccountType = () => {
return ( return (
<Pref <Pref
alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0} alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}
title={t("account_usage_tier_title")} title={t("account_basics_tier_title")}
description={t("account_usage_tier_description")} description={t("account_basics_tier_description")}
> >
<div> <div>
{accountType} {accountType}
{account.billing?.paid_until && !account.billing?.cancel_at && {account.billing?.paid_until && !account.billing?.cancel_at &&
<Tooltip title={t("account_usage_tier_paid_until", { date: formatShortDate(account.billing?.paid_until) })}> <Tooltip title={t("account_basics_tier_paid_until", { date: formatShortDate(account.billing?.paid_until) })}>
<span><InfoIcon/></span> <span><InfoIcon/></span>
</Tooltip> </Tooltip>
} }
@ -270,7 +270,7 @@ const AccountType = () => {
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>} startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>}
onClick={handleUpgradeClick} onClick={handleUpgradeClick}
sx={{ml: 1}} sx={{ml: 1}}
>{t("account_usage_tier_upgrade_button")}</Button> >{t("account_basics_tier_upgrade_button")}</Button>
} }
{config.enable_payments && account.role === Role.USER && account.billing?.subscription && {config.enable_payments && account.role === Role.USER && account.billing?.subscription &&
<Button <Button
@ -278,7 +278,7 @@ const AccountType = () => {
size="small" size="small"
onClick={handleUpgradeClick} onClick={handleUpgradeClick}
sx={{ml: 1}} sx={{ml: 1}}
>{t("account_usage_tier_change_button")}</Button> >{t("account_basics_tier_change_button")}</Button>
} }
{config.enable_payments && account.role === Role.USER && account.billing?.customer && {config.enable_payments && account.role === Role.USER && account.billing?.customer &&
<Button <Button
@ -286,19 +286,21 @@ const AccountType = () => {
size="small" size="small"
onClick={handleManageBilling} onClick={handleManageBilling}
sx={{ml: 1}} sx={{ml: 1}}
>{t("account_usage_manage_billing_button")}</Button> >{t("account_basics_tier_manage_billing_button")}</Button>
}
{config.enable_payments &&
<UpgradeDialog
key={`upgradeDialogFromAccount${upgradeDialogKey}`}
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
} }
<UpgradeDialog
key={`upgradeDialogFromAccount${upgradeDialogKey}`}
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
</div> </div>
{account.billing?.status === SubscriptionStatus.PAST_DUE && {account.billing?.status === SubscriptionStatus.PAST_DUE &&
<Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert> <Alert severity="error" sx={{mt: 1}}>{t("account_basics_tier_payment_overdue")}</Alert>
} }
{account.billing?.cancel_at > 0 && {account.billing?.cancel_at > 0 &&
<Alert severity="warning" sx={{mt: 1}}>{t("account_usage_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert> <Alert severity="warning" sx={{mt: 1}}>{t("account_basics_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert>
} }
<Portal> <Portal>
<Snackbar <Snackbar

View File

@ -212,7 +212,7 @@ const TierCard = (props) => {
}}>{labelText}</div> }}>{labelText}</div>
} }
<Typography variant="h5" component="div"> <Typography variant="h5" component="div">
{tier.name || t("account_usage_tier_free")} {tier.name || t("account_basics_tier_free")}
</Typography> </Typography>
<List dense> <List dense>
{tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>} {tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>}

View File

@ -1,8 +1,6 @@
import config from "../app/config"; import config from "../app/config";
import {shortUrl} from "../app/utils"; import {shortUrl} from "../app/utils";
// Remember to also update the "disallowedTopics" list!
const routes = { const routes = {
login: "/login", login: "/login",
signup: "/signup", signup: "/signup",