Self-review, round 2
This commit is contained in:
		
							parent
							
								
									bcb22d8d4c
								
							
						
					
					
						commit
						e6bb5f484c
					
				
					 24 changed files with 288 additions and 183 deletions
				
			
		|  | @ -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. | ||||||
| `, | `, | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
|  | @ -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")) | ||||||
|  |  | ||||||
|  | @ -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.`, | ||||||
| 		}, | 		}, | ||||||
| 	}, | 	}, | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
| `, | `, | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
|  | @ -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 { | ||||||
|  |  | ||||||
|  | @ -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() | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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 |  | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										169
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										169
									
								
								server/server.go
									
										
									
									
									
								
							|  | @ -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 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -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! | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|  | @ -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() | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
| 		}) | 		}) | ||||||
|  |  | ||||||
|  | @ -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 { | ||||||
|  |  | ||||||
|  | @ -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") | ||||||
|  |  | ||||||
|  | @ -1,4 +1,3 @@ | ||||||
| // Package user deals with authentication and authorization against topics |  | ||||||
| package user | package user | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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", | ||||||
|  |  | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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>} | ||||||
|  |  | ||||||
|  | @ -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", | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue