Display name sync
This commit is contained in:
		
							parent
							
								
									7ae8049438
								
							
						
					
					
						commit
						2fb4bd4975
					
				
					 12 changed files with 897 additions and 793 deletions
				
			
		|  | @ -355,6 +355,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit | ||||||
| 		return s.handleAccountSettingsChange(w, r, v) | 		return s.handleAccountSettingsChange(w, r, v) | ||||||
| 	} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath { | 	} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath { | ||||||
| 		return s.handleAccountSubscriptionAdd(w, r, v) | 		return s.handleAccountSubscriptionAdd(w, r, v) | ||||||
|  | 	} else if r.Method == http.MethodPatch && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { | ||||||
|  | 		return s.handleAccountSubscriptionChange(w, r, v) | ||||||
| 	} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { | 	} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { | ||||||
| 		return s.handleAccountSubscriptionDelete(w, r, v) | 		return s.handleAccountSubscriptionDelete(w, r, v) | ||||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { | 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"heckel.io/ntfy/user" | 	"heckel.io/ntfy/user" | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
|  | 	"io" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -244,40 +245,75 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req | ||||||
| 	if v.user == nil { | 	if v.user == nil { | ||||||
| 		return errors.New("no user") | 		return errors.New("no user") | ||||||
| 	} | 	} | ||||||
| 	w.Header().Set("Content-Type", "application/json") | 	newSubscription, err := readJSONBody[user.Subscription](r.Body) | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this |  | ||||||
| 	body, err := util.Peek(r.Body, 4096)               // FIXME |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	defer r.Body.Close() |  | ||||||
| 	var newSubscription user.Subscription |  | ||||||
| 	if err := json.NewDecoder(body).Decode(&newSubscription); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if v.user.Prefs == nil { | 	if v.user.Prefs == nil { | ||||||
| 		v.user.Prefs = &user.Prefs{} | 		v.user.Prefs = &user.Prefs{} | ||||||
| 	} | 	} | ||||||
| 	newSubscription.ID = "" // Client cannot set ID | 	newSubscription.ID = "" // Client cannot set ID | ||||||
| 	for _, subscription := range v.user.Prefs.Subscriptions { | 	for _, subscription := range v.user.Prefs.Subscriptions { | ||||||
| 		if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { | 		if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { | ||||||
| 			newSubscription = *subscription | 			newSubscription = subscription | ||||||
| 			break | 			break | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if newSubscription.ID == "" { | 	if newSubscription.ID == "" { | ||||||
| 		newSubscription.ID = util.RandomString(16) | 		newSubscription.ID = util.RandomString(16) | ||||||
| 		v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, &newSubscription) | 		v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription) | ||||||
| 		if err := s.userManager.ChangeSettings(v.user); err != nil { | 		if err := s.userManager.ChangeSettings(v.user); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
| 	if err := json.NewEncoder(w).Encode(newSubscription); err != nil { | 	if err := json.NewEncoder(w).Encode(newSubscription); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||||
|  | 	if v.user == nil { | ||||||
|  | 		return errors.New("no user") // FIXME s.ensureUser | ||||||
|  | 	} | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
|  | 	matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path) | ||||||
|  | 	if len(matches) != 2 { | ||||||
|  | 		return errHTTPInternalErrorInvalidFilePath // FIXME | ||||||
|  | 	} | ||||||
|  | 	updatedSubscription, err := readJSONBody[user.Subscription](r.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	subscriptionID := matches[1] | ||||||
|  | 	if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil { | ||||||
|  | 		return errHTTPNotFound | ||||||
|  | 	} | ||||||
|  | 	var subscription *user.Subscription | ||||||
|  | 	for _, sub := range v.user.Prefs.Subscriptions { | ||||||
|  | 		if sub.ID == subscriptionID { | ||||||
|  | 			sub.DisplayName = updatedSubscription.DisplayName | ||||||
|  | 			subscription = sub | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if subscription == nil { | ||||||
|  | 		return errHTTPNotFound | ||||||
|  | 	} | ||||||
|  | 	if err := s.userManager.ChangeSettings(v.user); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
|  | 	if err := json.NewEncoder(w).Encode(subscription); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { | func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||||
| 	if v.user == nil { | 	if v.user == nil { | ||||||
| 		return errors.New("no user") | 		return errors.New("no user") | ||||||
|  | @ -306,3 +342,16 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func readJSONBody[T any](body io.ReadCloser) (*T, error) { | ||||||
|  | 	body, err := util.Peek(body, 4096) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer body.Close() | ||||||
|  | 	var obj T | ||||||
|  | 	if err := json.NewDecoder(body).Decode(&obj); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return &obj, nil | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										672
									
								
								user/manager.go
									
										
									
									
									
								
							
							
						
						
									
										672
									
								
								user/manager.go
									
										
									
									
									
								
							|  | @ -1,176 +1,592 @@ | ||||||
| // Package auth deals with authentication and authorization against topics |  | ||||||
| package user | package user | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"database/sql" | ||||||
|  | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"regexp" | 	"fmt" | ||||||
|  | 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||||
|  | 	"golang.org/x/crypto/bcrypt" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
|  | 	"heckel.io/ntfy/util" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
|  | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Manager is a generic interface to implement password and token based authentication and authorization | const ( | ||||||
| type Manager interface { | 	tokenLength                  = 32 | ||||||
|  | 	bcryptCost                   = 10 | ||||||
|  | 	intentionalSlowDownHash      = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost | ||||||
|  | 	userStatsQueueWriterInterval = 33 * time.Second | ||||||
|  | 	userTokenExpiryDuration      = 72 * time.Hour | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Manager-related queries | ||||||
|  | const ( | ||||||
|  | 	createAuthTablesQueries = ` | ||||||
|  | 		BEGIN; | ||||||
|  | 		CREATE TABLE IF NOT EXISTS plan ( | ||||||
|  | 			id INT NOT NULL,		 | ||||||
|  | 			code TEXT NOT NULL, | ||||||
|  | 			messages_limit INT NOT NULL, | ||||||
|  | 			emails_limit INT NOT NULL, | ||||||
|  | 			attachment_file_size_limit INT NOT NULL, | ||||||
|  | 			attachment_total_size_limit INT NOT NULL, | ||||||
|  | 			PRIMARY KEY (id) | ||||||
|  | 		); | ||||||
|  | 		CREATE TABLE IF NOT EXISTS user ( | ||||||
|  | 		    id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 			plan_id INT, | ||||||
|  | 			user TEXT NOT NULL, | ||||||
|  | 			pass TEXT NOT NULL, | ||||||
|  | 			role TEXT NOT NULL, | ||||||
|  | 			messages INT NOT NULL DEFAULT (0), | ||||||
|  | 			emails INT NOT NULL DEFAULT (0),			 | ||||||
|  | 			settings JSON, | ||||||
|  | 		    FOREIGN KEY (plan_id) REFERENCES plan (id) | ||||||
|  | 		); | ||||||
|  | 		CREATE UNIQUE INDEX idx_user ON user (user); | ||||||
|  | 		CREATE TABLE IF NOT EXISTS user_access ( | ||||||
|  | 			user_id INT NOT NULL,		 | ||||||
|  | 			topic TEXT NOT NULL, | ||||||
|  | 			read INT NOT NULL, | ||||||
|  | 			write INT NOT NULL, | ||||||
|  | 			PRIMARY KEY (user_id, topic), | ||||||
|  | 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE | ||||||
|  | 		);		 | ||||||
|  | 		CREATE TABLE IF NOT EXISTS user_token ( | ||||||
|  | 			user_id INT NOT NULL, | ||||||
|  | 			token TEXT NOT NULL, | ||||||
|  | 			expires INT NOT NULL, | ||||||
|  | 			PRIMARY KEY (user_id, token), | ||||||
|  | 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE | ||||||
|  | 		); | ||||||
|  | 		CREATE TABLE IF NOT EXISTS schemaVersion ( | ||||||
|  | 			id INT PRIMARY KEY, | ||||||
|  | 			version INT NOT NULL | ||||||
|  | 		); | ||||||
|  | 		INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING; | ||||||
|  | 		COMMIT; | ||||||
|  | 	` | ||||||
|  | 	selectUserByNameQuery = ` | ||||||
|  | 		SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit | ||||||
|  | 		FROM user u | ||||||
|  | 		LEFT JOIN plan p on p.id = u.plan_id | ||||||
|  | 		WHERE user = ?		 | ||||||
|  | 	` | ||||||
|  | 	selectUserByTokenQuery = ` | ||||||
|  | 		SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit | ||||||
|  | 		FROM user u | ||||||
|  | 		JOIN user_token t on u.id = t.user_id | ||||||
|  | 		LEFT JOIN plan p on p.id = u.plan_id | ||||||
|  | 		WHERE t.token = ? | ||||||
|  | 	` | ||||||
|  | 	selectTopicPermsQuery = ` | ||||||
|  | 		SELECT read, write  | ||||||
|  | 		FROM user_access | ||||||
|  | 		JOIN user ON user.user = '*' OR user.user = ? | ||||||
|  | 		WHERE ? LIKE user_access.topic | ||||||
|  | 		ORDER BY user.user DESC | ||||||
|  | 	` | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Manager-related queries | ||||||
|  | const ( | ||||||
|  | 	insertUserQuery         = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` | ||||||
|  | 	selectUsernamesQuery    = `SELECT user FROM user ORDER BY role, user` | ||||||
|  | 	updateUserPassQuery     = `UPDATE user SET pass = ? WHERE user = ?` | ||||||
|  | 	updateUserRoleQuery     = `UPDATE user SET role = ? WHERE user = ?` | ||||||
|  | 	updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?` | ||||||
|  | 	updateUserStatsQuery    = `UPDATE user SET messages = ?, emails = ? WHERE user = ?` | ||||||
|  | 	deleteUserQuery         = `DELETE FROM user WHERE user = ?` | ||||||
|  | 
 | ||||||
|  | 	upsertUserAccessQuery  = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)` | ||||||
|  | 	selectUserAccessQuery  = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` | ||||||
|  | 	deleteAllAccessQuery   = `DELETE FROM user_access` | ||||||
|  | 	deleteUserAccessQuery  = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` | ||||||
|  | 	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` | ||||||
|  | 
 | ||||||
|  | 	insertTokenQuery         = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` | ||||||
|  | 	updateTokenExpiryQuery   = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` | ||||||
|  | 	deleteTokenQuery         = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` | ||||||
|  | 	deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` | ||||||
|  | 	deleteUserTokensQuery    = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)` | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Schema management queries | ||||||
|  | const ( | ||||||
|  | 	currentSchemaVersion     = 1 | ||||||
|  | 	insertSchemaVersion      = `INSERT INTO schemaVersion VALUES (1, ?)` | ||||||
|  | 	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // SQLiteManager is an implementation of Manager. It stores users and access control list | ||||||
|  | // in a SQLite database. | ||||||
|  | type SQLiteManager struct { | ||||||
|  | 	db           *sql.DB | ||||||
|  | 	defaultRead  bool | ||||||
|  | 	defaultWrite bool | ||||||
|  | 	statsQueue   map[string]*User // Username -> User, for "unimportant" user updates | ||||||
|  | 	mu           sync.Mutex | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | var _ Manager = (*SQLiteManager)(nil) | ||||||
|  | 
 | ||||||
|  | // NewSQLiteAuthManager creates a new SQLiteManager instance | ||||||
|  | func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteManager, error) { | ||||||
|  | 	db, err := sql.Open("sqlite3", filename) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	if err := setupAuthDB(db); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	manager := &SQLiteManager{ | ||||||
|  | 		db:           db, | ||||||
|  | 		defaultRead:  defaultRead, | ||||||
|  | 		defaultWrite: defaultWrite, | ||||||
|  | 		statsQueue:   make(map[string]*User), | ||||||
|  | 	} | ||||||
|  | 	go manager.userStatsQueueWriter() | ||||||
|  | 	return manager, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // Authenticate checks username and password and returns a user if correct. The method | // Authenticate checks username and password and returns a user if correct. The method | ||||||
| // returns in constant-ish time, regardless of whether the user exists or the password is | // returns in constant-ish time, regardless of whether the user exists or the password is | ||||||
| // correct or incorrect. | // correct or incorrect. | ||||||
| 	Authenticate(username, password string) (*User, error) | func (a *SQLiteManager) Authenticate(username, password string) (*User, error) { | ||||||
|  | 	if username == Everyone { | ||||||
|  | 		return nil, ErrUnauthenticated | ||||||
|  | 	} | ||||||
|  | 	user, err := a.User(username) | ||||||
|  | 	if err != nil { | ||||||
|  | 		bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), | ||||||
|  | 			[]byte("intentional slow-down to avoid timing attacks")) | ||||||
|  | 		return nil, ErrUnauthenticated | ||||||
|  | 	} | ||||||
|  | 	if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { | ||||||
|  | 		return nil, ErrUnauthenticated | ||||||
|  | 	} | ||||||
|  | 	return user, nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 	AuthenticateToken(token string) (*User, error) | func (a *SQLiteManager) AuthenticateToken(token string) (*User, error) { | ||||||
| 	CreateToken(user *User) (*Token, error) | 	user, err := a.userByToken(token) | ||||||
| 	ExtendToken(user *User) (*Token, error) | 	if err != nil { | ||||||
| 	RemoveToken(user *User) error | 		return nil, ErrUnauthenticated | ||||||
| 	RemoveExpiredTokens() error | 	} | ||||||
| 	ChangeSettings(user *User) error | 	user.Token = token | ||||||
| 	EnqueueStats(user *User) | 	return user, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) CreateToken(user *User) (*Token, error) { | ||||||
|  | 	token := util.RandomString(tokenLength) | ||||||
|  | 	expires := time.Now().Add(userTokenExpiryDuration) | ||||||
|  | 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires.Unix()); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return &Token{ | ||||||
|  | 		Value:   token, | ||||||
|  | 		Expires: expires.Unix(), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) ExtendToken(user *User) (*Token, error) { | ||||||
|  | 	newExpires := time.Now().Add(userTokenExpiryDuration) | ||||||
|  | 	if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return &Token{ | ||||||
|  | 		Value:   user.Token, | ||||||
|  | 		Expires: newExpires.Unix(), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) RemoveToken(user *User) error { | ||||||
|  | 	if user.Token == "" { | ||||||
|  | 		return ErrUnauthorized | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) RemoveExpiredTokens() error { | ||||||
|  | 	if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) ChangeSettings(user *User) error { | ||||||
|  | 	settings, err := json.Marshal(user.Prefs) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(updateUserSettingsQuery, string(settings), user.Name); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) EnqueueStats(user *User) { | ||||||
|  | 	a.mu.Lock() | ||||||
|  | 	defer a.mu.Unlock() | ||||||
|  | 	a.statsQueue[user.Name] = user | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) userStatsQueueWriter() { | ||||||
|  | 	ticker := time.NewTicker(userStatsQueueWriterInterval) | ||||||
|  | 	for range ticker.C { | ||||||
|  | 		if err := a.writeUserStatsQueue(); err != nil { | ||||||
|  | 			log.Warn("UserManager: Writing user stats queue failed: %s", err.Error()) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) writeUserStatsQueue() error { | ||||||
|  | 	a.mu.Lock() | ||||||
|  | 	if len(a.statsQueue) == 0 { | ||||||
|  | 		a.mu.Unlock() | ||||||
|  | 		log.Trace("UserManager: No user stats updates to commit") | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	statsQueue := a.statsQueue | ||||||
|  | 	a.statsQueue = make(map[string]*User) | ||||||
|  | 	a.mu.Unlock() | ||||||
|  | 	tx, err := a.db.Begin() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer tx.Rollback() | ||||||
|  | 	log.Debug("UserManager: Writing user stats queue for %d user(s)", len(statsQueue)) | ||||||
|  | 	for username, u := range statsQueue { | ||||||
|  | 		log.Trace("UserManager: Updating stats for user %s: messages=%d, emails=%d", username, u.Stats.Messages, u.Stats.Emails) | ||||||
|  | 		if _, err := tx.Exec(updateUserStatsQuery, u.Stats.Messages, u.Stats.Emails, username); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return tx.Commit() | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // Authorize returns nil if the given user has access to the given topic using the desired | // Authorize returns nil if the given user has access to the given topic using the desired | ||||||
| // permission. The user param may be nil to signal an anonymous user. | // permission. The user param may be nil to signal an anonymous user. | ||||||
| 	Authorize(user *User, topic string, perm Permission) error | func (a *SQLiteManager) Authorize(user *User, topic string, perm Permission) error { | ||||||
|  | 	if user != nil && user.Role == RoleAdmin { | ||||||
|  | 		return nil // Admin can do everything | ||||||
|  | 	} | ||||||
|  | 	username := Everyone | ||||||
|  | 	if user != nil { | ||||||
|  | 		username = user.Name | ||||||
|  | 	} | ||||||
|  | 	// Select the read/write permissions for this user/topic combo. The query may return two | ||||||
|  | 	// rows (one for everyone, and one for the user), but prioritizes the user. The value for | ||||||
|  | 	// user.Name may be empty (= everyone). | ||||||
|  | 	rows, err := a.db.Query(selectTopicPermsQuery, username, topic) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer rows.Close() | ||||||
|  | 	if !rows.Next() { | ||||||
|  | 		return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) | ||||||
|  | 	} | ||||||
|  | 	var read, write bool | ||||||
|  | 	if err := rows.Scan(&read, &write); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} else if err := rows.Err(); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return a.resolvePerms(read, write, perm) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) resolvePerms(read, write bool, perm Permission) error { | ||||||
|  | 	if perm == PermissionRead && read { | ||||||
|  | 		return nil | ||||||
|  | 	} else if perm == PermissionWrite && write { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	return ErrUnauthorized | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // AddUser adds a user with the given username, password and role. The password should be hashed | // AddUser adds a user with the given username, password and role. The password should be hashed | ||||||
| // before it is stored in a persistence layer. | // before it is stored in a persistence layer. | ||||||
| 	AddUser(username, password string, role Role) error | func (a *SQLiteManager) AddUser(username, password string, role Role) error { | ||||||
|  | 	if !AllowedUsername(username) || !AllowedRole(role) { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} | ||||||
|  | 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // RemoveUser deletes the user with the given username. The function returns nil on success, even | // RemoveUser deletes the user with the given username. The function returns nil on success, even | ||||||
| // if the user did not exist in the first place. | // if the user did not exist in the first place. | ||||||
| 	RemoveUser(username string) error | func (a *SQLiteManager) RemoveUser(username string) error { | ||||||
|  | 	if !AllowedUsername(username) { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(deleteUserTokensQuery, username); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(deleteUserQuery, username); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // Users returns a list of users. It always also returns the Everyone user ("*"). | // Users returns a list of users. It always also returns the Everyone user ("*"). | ||||||
| 	Users() ([]*User, error) | func (a *SQLiteManager) Users() ([]*User, error) { | ||||||
|  | 	rows, err := a.db.Query(selectUsernamesQuery) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer rows.Close() | ||||||
|  | 	usernames := make([]string, 0) | ||||||
|  | 	for rows.Next() { | ||||||
|  | 		var username string | ||||||
|  | 		if err := rows.Scan(&username); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} else if err := rows.Err(); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 		usernames = append(usernames, username) | ||||||
|  | 	} | ||||||
|  | 	rows.Close() | ||||||
|  | 	users := make([]*User, 0) | ||||||
|  | 	for _, username := range usernames { | ||||||
|  | 		user, err := a.User(username) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 		users = append(users, user) | ||||||
|  | 	} | ||||||
|  | 	everyone, err := a.everyoneUser() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	users = append(users, everyone) | ||||||
|  | 	return users, nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // User returns the user with the given username if it exists, or ErrNotFound otherwise. | // User returns the user with the given username if it exists, or ErrNotFound otherwise. | ||||||
| // You may also pass Everyone to retrieve the anonymous user and its Grant list. | // You may also pass Everyone to retrieve the anonymous user and its Grant list. | ||||||
| 	User(username string) (*User, error) | func (a *SQLiteManager) User(username string) (*User, error) { | ||||||
|  | 	if username == Everyone { | ||||||
|  | 		return a.everyoneUser() | ||||||
|  | 	} | ||||||
|  | 	rows, err := a.db.Query(selectUserByNameQuery, username) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return a.readUser(rows) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) userByToken(token string) (*User, error) { | ||||||
|  | 	rows, err := a.db.Query(selectUserByTokenQuery, token) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return a.readUser(rows) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) readUser(rows *sql.Rows) (*User, error) { | ||||||
|  | 	defer rows.Close() | ||||||
|  | 	var username, hash, role string | ||||||
|  | 	var settings, planCode sql.NullString | ||||||
|  | 	var messages, emails int64 | ||||||
|  | 	var messagesLimit, emailsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit sql.NullInt64 | ||||||
|  | 	if !rows.Next() { | ||||||
|  | 		return nil, ErrNotFound | ||||||
|  | 	} | ||||||
|  | 	if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &planCode, &messagesLimit, &emailsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} else if err := rows.Err(); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	grants, err := a.readGrants(username) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	user := &User{ | ||||||
|  | 		Name:   username, | ||||||
|  | 		Hash:   hash, | ||||||
|  | 		Role:   Role(role), | ||||||
|  | 		Grants: grants, | ||||||
|  | 		Stats: &Stats{ | ||||||
|  | 			Messages: messages, | ||||||
|  | 			Emails:   emails, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	if settings.Valid { | ||||||
|  | 		user.Prefs = &Prefs{} | ||||||
|  | 		if err := json.Unmarshal([]byte(settings.String), user.Prefs); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if planCode.Valid { | ||||||
|  | 		user.Plan = &Plan{ | ||||||
|  | 			Code:                     planCode.String, | ||||||
|  | 			Upgradable:               true, // FIXME | ||||||
|  | 			MessagesLimit:            messagesLimit.Int64, | ||||||
|  | 			EmailsLimit:              emailsLimit.Int64, | ||||||
|  | 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64, | ||||||
|  | 			AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return user, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) everyoneUser() (*User, error) { | ||||||
|  | 	grants, err := a.readGrants(Everyone) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return &User{ | ||||||
|  | 		Name:   Everyone, | ||||||
|  | 		Hash:   "", | ||||||
|  | 		Role:   RoleAnonymous, | ||||||
|  | 		Grants: grants, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *SQLiteManager) readGrants(username string) ([]Grant, error) { | ||||||
|  | 	rows, err := a.db.Query(selectUserAccessQuery, username) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer rows.Close() | ||||||
|  | 	grants := make([]Grant, 0) | ||||||
|  | 	for rows.Next() { | ||||||
|  | 		var topic string | ||||||
|  | 		var read, write bool | ||||||
|  | 		if err := rows.Scan(&topic, &read, &write); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} else if err := rows.Err(); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 		grants = append(grants, Grant{ | ||||||
|  | 			TopicPattern: fromSQLWildcard(topic), | ||||||
|  | 			AllowRead:    read, | ||||||
|  | 			AllowWrite:   write, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 	return grants, nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // ChangePassword changes a user's password | // ChangePassword changes a user's password | ||||||
| 	ChangePassword(username, password string) error | func (a *SQLiteManager) ChangePassword(username, password string) error { | ||||||
|  | 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, | // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, | ||||||
| // all existing access control entries (Grant) are removed, since they are no longer needed. | // all existing access control entries (Grant) are removed, since they are no longer needed. | ||||||
| 	ChangeRole(username string, role Role) error | func (a *SQLiteManager) ChangeRole(username string, role Role) error { | ||||||
|  | 	if !AllowedUsername(username) || !AllowedRole(role) { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if role == RoleAdmin { | ||||||
|  | 		if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // AllowAccess adds or updates an entry in th access control list for a specific user. It controls | // AllowAccess adds or updates an entry in th access control list for a specific user. It controls | ||||||
| // read/write access to a topic. The parameter topicPattern may include wildcards (*). | // read/write access to a topic. The parameter topicPattern may include wildcards (*). | ||||||
| 	AllowAccess(username string, topicPattern string, read bool, write bool) error | func (a *SQLiteManager) AllowAccess(username string, topicPattern string, read bool, write bool) error { | ||||||
|  | 	if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is | // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is | ||||||
| // empty) for an entire user. The parameter topicPattern may include wildcards (*). | // empty) for an entire user. The parameter topicPattern may include wildcards (*). | ||||||
| 	ResetAccess(username string, topicPattern string) error | func (a *SQLiteManager) ResetAccess(username string, topicPattern string) error { | ||||||
|  | 	if !AllowedUsername(username) && username != Everyone && username != "" { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { | ||||||
|  | 		return ErrInvalidArgument | ||||||
|  | 	} | ||||||
|  | 	if username == "" && topicPattern == "" { | ||||||
|  | 		_, err := a.db.Exec(deleteAllAccessQuery, username) | ||||||
|  | 		return err | ||||||
|  | 	} else if topicPattern == "" { | ||||||
|  | 		_, err := a.db.Exec(deleteUserAccessQuery, username) | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) | ||||||
|  | 	return err | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // DefaultAccess returns the default read/write access if no access control entry matches | // DefaultAccess returns the default read/write access if no access control entry matches | ||||||
| 	DefaultAccess() (read bool, write bool) | func (a *SQLiteManager) DefaultAccess() (read bool, write bool) { | ||||||
|  | 	return a.defaultRead, a.defaultWrite | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // User is a struct that represents a user | func toSQLWildcard(s string) string { | ||||||
| type User struct { | 	return strings.ReplaceAll(s, "*", "%") | ||||||
| 	Name   string |  | ||||||
| 	Hash   string // password hash (bcrypt) |  | ||||||
| 	Token  string // Only set if token was used to log in |  | ||||||
| 	Role   Role |  | ||||||
| 	Grants []Grant |  | ||||||
| 	Prefs  *Prefs |  | ||||||
| 	Plan   *Plan |  | ||||||
| 	Stats  *Stats |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type Token struct { | func fromSQLWildcard(s string) string { | ||||||
| 	Value   string | 	return strings.ReplaceAll(s, "%", "*") | ||||||
| 	Expires int64 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type Prefs struct { | func setupAuthDB(db *sql.DB) error { | ||||||
| 	Language      string             `json:"language,omitempty"` | 	// If 'schemaVersion' table does not exist, this must be a new database | ||||||
| 	Notification  *NotificationPrefs `json:"notification,omitempty"` | 	rowsSV, err := db.Query(selectSchemaVersionQuery) | ||||||
| 	Subscriptions []*Subscription    `json:"subscriptions,omitempty"` | 	if err != nil { | ||||||
|  | 		return setupNewAuthDB(db) | ||||||
|  | 	} | ||||||
|  | 	defer rowsSV.Close() | ||||||
|  | 
 | ||||||
|  | 	// If 'schemaVersion' table exists, read version and potentially upgrade | ||||||
|  | 	schemaVersion := 0 | ||||||
|  | 	if !rowsSV.Next() { | ||||||
|  | 		return errors.New("cannot determine schema version: database file may be corrupt") | ||||||
|  | 	} | ||||||
|  | 	if err := rowsSV.Scan(&schemaVersion); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	rowsSV.Close() | ||||||
|  | 
 | ||||||
|  | 	// Do migrations | ||||||
|  | 	if schemaVersion == currentSchemaVersion { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type PlanCode string | func setupNewAuthDB(db *sql.DB) error { | ||||||
| 
 | 	if _, err := db.Exec(createAuthTablesQueries); err != nil { | ||||||
| const ( | 		return err | ||||||
| 	PlanUnlimited = PlanCode("unlimited") |  | ||||||
| 	PlanDefault   = PlanCode("default") |  | ||||||
| 	PlanNone      = PlanCode("none") |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| type Plan struct { |  | ||||||
| 	Code                     string `json:"name"` |  | ||||||
| 	Upgradable               bool   `json:"upgradable"` |  | ||||||
| 	MessagesLimit            int64  `json:"messages_limit"` |  | ||||||
| 	EmailsLimit              int64  `json:"emails_limit"` |  | ||||||
| 	AttachmentFileSizeLimit  int64  `json:"attachment_file_size_limit"` |  | ||||||
| 	AttachmentTotalSizeLimit int64  `json:"attachment_total_size_limit"` |  | ||||||
| 	} | 	} | ||||||
| 
 | 	if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { | ||||||
| type Subscription struct { | 		return err | ||||||
| 	ID      string `json:"id"` |  | ||||||
| 	BaseURL string `json:"base_url"` |  | ||||||
| 	Topic   string `json:"topic"` |  | ||||||
| 	} | 	} | ||||||
| 
 | 	return nil | ||||||
| type NotificationPrefs struct { |  | ||||||
| 	Sound       string `json:"sound,omitempty"` |  | ||||||
| 	MinPriority int    `json:"min_priority,omitempty"` |  | ||||||
| 	DeleteAfter int    `json:"delete_after,omitempty"` |  | ||||||
| } | } | ||||||
| 
 |  | ||||||
| type Stats struct { |  | ||||||
| 	Messages int64 |  | ||||||
| 	Emails   int64 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Grant is a struct that represents an access control entry to a topic |  | ||||||
| type Grant struct { |  | ||||||
| 	TopicPattern string // May include wildcard (*) |  | ||||||
| 	AllowRead    bool |  | ||||||
| 	AllowWrite   bool |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Permission represents a read or write permission to a topic |  | ||||||
| type Permission int |  | ||||||
| 
 |  | ||||||
| // Permissions to a topic |  | ||||||
| const ( |  | ||||||
| 	PermissionRead  = Permission(1) |  | ||||||
| 	PermissionWrite = Permission(2) |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // Role represents a user's role, either admin or regular user |  | ||||||
| type Role string |  | ||||||
| 
 |  | ||||||
| // User roles |  | ||||||
| const ( |  | ||||||
| 	RoleAdmin     = Role("admin") |  | ||||||
| 	RoleUser      = Role("user") |  | ||||||
| 	RoleAnonymous = Role("anonymous") |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // Everyone is a special username representing anonymous users |  | ||||||
| const ( |  | ||||||
| 	Everyone = "*" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| var ( |  | ||||||
| 	allowedUsernameRegex     = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`)     // Does not include Everyone (*) |  | ||||||
| 	allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // AllowedRole returns true if the given role can be used for new users |  | ||||||
| func AllowedRole(role Role) bool { |  | ||||||
| 	return role == RoleUser || role == RoleAdmin |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // AllowedUsername returns true if the given username is valid |  | ||||||
| func AllowedUsername(username string) bool { |  | ||||||
| 	return allowedUsernameRegex.MatchString(username) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) |  | ||||||
| func AllowedTopicPattern(username string) bool { |  | ||||||
| 	return allowedTopicPatternRegex.MatchString(username) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Error constants used by the package |  | ||||||
| var ( |  | ||||||
| 	ErrUnauthenticated = errors.New("unauthenticated") |  | ||||||
| 	ErrUnauthorized    = errors.New("unauthorized") |  | ||||||
| 	ErrInvalidArgument = errors.New("invalid argument") |  | ||||||
| 	ErrNotFound        = errors.New("not found") |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  | @ -1,592 +0,0 @@ | ||||||
| package user |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"database/sql" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"errors" |  | ||||||
| 	"fmt" |  | ||||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver |  | ||||||
| 	"golang.org/x/crypto/bcrypt" |  | ||||||
| 	"heckel.io/ntfy/log" |  | ||||||
| 	"heckel.io/ntfy/util" |  | ||||||
| 	"strings" |  | ||||||
| 	"sync" |  | ||||||
| 	"time" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| const ( |  | ||||||
| 	tokenLength                  = 32 |  | ||||||
| 	bcryptCost                   = 10 |  | ||||||
| 	intentionalSlowDownHash      = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost |  | ||||||
| 	userStatsQueueWriterInterval = 33 * time.Second |  | ||||||
| 	userTokenExpiryDuration      = 72 * time.Hour |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // Manager-related queries |  | ||||||
| const ( |  | ||||||
| 	createAuthTablesQueries = ` |  | ||||||
| 		BEGIN; |  | ||||||
| 		CREATE TABLE IF NOT EXISTS plan ( |  | ||||||
| 			id INT NOT NULL,		 |  | ||||||
| 			code TEXT NOT NULL, |  | ||||||
| 			messages_limit INT NOT NULL, |  | ||||||
| 			emails_limit INT NOT NULL, |  | ||||||
| 			attachment_file_size_limit INT NOT NULL, |  | ||||||
| 			attachment_total_size_limit INT NOT NULL, |  | ||||||
| 			PRIMARY KEY (id) |  | ||||||
| 		); |  | ||||||
| 		CREATE TABLE IF NOT EXISTS user ( |  | ||||||
| 		    id INTEGER PRIMARY KEY AUTOINCREMENT, |  | ||||||
| 			plan_id INT, |  | ||||||
| 			user TEXT NOT NULL, |  | ||||||
| 			pass TEXT NOT NULL, |  | ||||||
| 			role TEXT NOT NULL, |  | ||||||
| 			messages INT NOT NULL DEFAULT (0), |  | ||||||
| 			emails INT NOT NULL DEFAULT (0),			 |  | ||||||
| 			settings JSON, |  | ||||||
| 		    FOREIGN KEY (plan_id) REFERENCES plan (id) |  | ||||||
| 		); |  | ||||||
| 		CREATE UNIQUE INDEX idx_user ON user (user); |  | ||||||
| 		CREATE TABLE IF NOT EXISTS user_access ( |  | ||||||
| 			user_id INT NOT NULL,		 |  | ||||||
| 			topic TEXT NOT NULL, |  | ||||||
| 			read INT NOT NULL, |  | ||||||
| 			write INT NOT NULL, |  | ||||||
| 			PRIMARY KEY (user_id, topic), |  | ||||||
| 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE |  | ||||||
| 		);		 |  | ||||||
| 		CREATE TABLE IF NOT EXISTS user_token ( |  | ||||||
| 			user_id INT NOT NULL, |  | ||||||
| 			token TEXT NOT NULL, |  | ||||||
| 			expires INT NOT NULL, |  | ||||||
| 			PRIMARY KEY (user_id, token), |  | ||||||
| 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE |  | ||||||
| 		); |  | ||||||
| 		CREATE TABLE IF NOT EXISTS schemaVersion ( |  | ||||||
| 			id INT PRIMARY KEY, |  | ||||||
| 			version INT NOT NULL |  | ||||||
| 		); |  | ||||||
| 		INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING; |  | ||||||
| 		COMMIT; |  | ||||||
| 	` |  | ||||||
| 	selectUserByNameQuery = ` |  | ||||||
| 		SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit |  | ||||||
| 		FROM user u |  | ||||||
| 		LEFT JOIN plan p on p.id = u.plan_id |  | ||||||
| 		WHERE user = ?		 |  | ||||||
| 	` |  | ||||||
| 	selectUserByTokenQuery = ` |  | ||||||
| 		SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit |  | ||||||
| 		FROM user u |  | ||||||
| 		JOIN user_token t on u.id = t.user_id |  | ||||||
| 		LEFT JOIN plan p on p.id = u.plan_id |  | ||||||
| 		WHERE t.token = ? |  | ||||||
| 	` |  | ||||||
| 	selectTopicPermsQuery = ` |  | ||||||
| 		SELECT read, write  |  | ||||||
| 		FROM user_access |  | ||||||
| 		JOIN user ON user.user = '*' OR user.user = ? |  | ||||||
| 		WHERE ? LIKE user_access.topic |  | ||||||
| 		ORDER BY user.user DESC |  | ||||||
| 	` |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // Manager-related queries |  | ||||||
| const ( |  | ||||||
| 	insertUserQuery         = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` |  | ||||||
| 	selectUsernamesQuery    = `SELECT user FROM user ORDER BY role, user` |  | ||||||
| 	updateUserPassQuery     = `UPDATE user SET pass = ? WHERE user = ?` |  | ||||||
| 	updateUserRoleQuery     = `UPDATE user SET role = ? WHERE user = ?` |  | ||||||
| 	updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?` |  | ||||||
| 	updateUserStatsQuery    = `UPDATE user SET messages = ?, emails = ? WHERE user = ?` |  | ||||||
| 	deleteUserQuery         = `DELETE FROM user WHERE user = ?` |  | ||||||
| 
 |  | ||||||
| 	upsertUserAccessQuery  = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)` |  | ||||||
| 	selectUserAccessQuery  = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` |  | ||||||
| 	deleteAllAccessQuery   = `DELETE FROM user_access` |  | ||||||
| 	deleteUserAccessQuery  = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` |  | ||||||
| 	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` |  | ||||||
| 
 |  | ||||||
| 	insertTokenQuery         = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` |  | ||||||
| 	updateTokenExpiryQuery   = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` |  | ||||||
| 	deleteTokenQuery         = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` |  | ||||||
| 	deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` |  | ||||||
| 	deleteUserTokensQuery    = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)` |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // Schema management queries |  | ||||||
| const ( |  | ||||||
| 	currentSchemaVersion     = 1 |  | ||||||
| 	insertSchemaVersion      = `INSERT INTO schemaVersion VALUES (1, ?)` |  | ||||||
| 	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // SQLiteManager is an implementation of Manager. It stores users and access control list |  | ||||||
| // in a SQLite database. |  | ||||||
| type SQLiteManager struct { |  | ||||||
| 	db           *sql.DB |  | ||||||
| 	defaultRead  bool |  | ||||||
| 	defaultWrite bool |  | ||||||
| 	statsQueue   map[string]*User // Username -> User, for "unimportant" user updates |  | ||||||
| 	mu           sync.Mutex |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| var _ Manager = (*SQLiteManager)(nil) |  | ||||||
| 
 |  | ||||||
| // NewSQLiteAuthManager creates a new SQLiteManager instance |  | ||||||
| func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteManager, error) { |  | ||||||
| 	db, err := sql.Open("sqlite3", filename) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	if err := setupAuthDB(db); err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	manager := &SQLiteManager{ |  | ||||||
| 		db:           db, |  | ||||||
| 		defaultRead:  defaultRead, |  | ||||||
| 		defaultWrite: defaultWrite, |  | ||||||
| 		statsQueue:   make(map[string]*User), |  | ||||||
| 	} |  | ||||||
| 	go manager.userStatsQueueWriter() |  | ||||||
| 	return manager, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Authenticate checks username and password and returns a user if correct. The method |  | ||||||
| // returns in constant-ish time, regardless of whether the user exists or the password is |  | ||||||
| // correct or incorrect. |  | ||||||
| func (a *SQLiteManager) Authenticate(username, password string) (*User, error) { |  | ||||||
| 	if username == Everyone { |  | ||||||
| 		return nil, ErrUnauthenticated |  | ||||||
| 	} |  | ||||||
| 	user, err := a.User(username) |  | ||||||
| 	if err != nil { |  | ||||||
| 		bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), |  | ||||||
| 			[]byte("intentional slow-down to avoid timing attacks")) |  | ||||||
| 		return nil, ErrUnauthenticated |  | ||||||
| 	} |  | ||||||
| 	if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { |  | ||||||
| 		return nil, ErrUnauthenticated |  | ||||||
| 	} |  | ||||||
| 	return user, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) AuthenticateToken(token string) (*User, error) { |  | ||||||
| 	user, err := a.userByToken(token) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, ErrUnauthenticated |  | ||||||
| 	} |  | ||||||
| 	user.Token = token |  | ||||||
| 	return user, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) CreateToken(user *User) (*Token, error) { |  | ||||||
| 	token := util.RandomString(tokenLength) |  | ||||||
| 	expires := time.Now().Add(userTokenExpiryDuration) |  | ||||||
| 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires.Unix()); err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	return &Token{ |  | ||||||
| 		Value:   token, |  | ||||||
| 		Expires: expires.Unix(), |  | ||||||
| 	}, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) ExtendToken(user *User) (*Token, error) { |  | ||||||
| 	newExpires := time.Now().Add(userTokenExpiryDuration) |  | ||||||
| 	if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	return &Token{ |  | ||||||
| 		Value:   user.Token, |  | ||||||
| 		Expires: newExpires.Unix(), |  | ||||||
| 	}, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) RemoveToken(user *User) error { |  | ||||||
| 	if user.Token == "" { |  | ||||||
| 		return ErrUnauthorized |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) RemoveExpiredTokens() error { |  | ||||||
| 	if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) ChangeSettings(user *User) error { |  | ||||||
| 	settings, err := json.Marshal(user.Prefs) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(updateUserSettingsQuery, string(settings), user.Name); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) EnqueueStats(user *User) { |  | ||||||
| 	a.mu.Lock() |  | ||||||
| 	defer a.mu.Unlock() |  | ||||||
| 	a.statsQueue[user.Name] = user |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) userStatsQueueWriter() { |  | ||||||
| 	ticker := time.NewTicker(userStatsQueueWriterInterval) |  | ||||||
| 	for range ticker.C { |  | ||||||
| 		if err := a.writeUserStatsQueue(); err != nil { |  | ||||||
| 			log.Warn("UserManager: Writing user stats queue failed: %s", err.Error()) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) writeUserStatsQueue() error { |  | ||||||
| 	a.mu.Lock() |  | ||||||
| 	if len(a.statsQueue) == 0 { |  | ||||||
| 		a.mu.Unlock() |  | ||||||
| 		log.Trace("UserManager: No user stats updates to commit") |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 	statsQueue := a.statsQueue |  | ||||||
| 	a.statsQueue = make(map[string]*User) |  | ||||||
| 	a.mu.Unlock() |  | ||||||
| 	tx, err := a.db.Begin() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	defer tx.Rollback() |  | ||||||
| 	log.Debug("UserManager: Writing user stats queue for %d user(s)", len(statsQueue)) |  | ||||||
| 	for username, u := range statsQueue { |  | ||||||
| 		log.Trace("UserManager: Updating stats for user %s: messages=%d, emails=%d", username, u.Stats.Messages, u.Stats.Emails) |  | ||||||
| 		if _, err := tx.Exec(updateUserStatsQuery, u.Stats.Messages, u.Stats.Emails, username); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return tx.Commit() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Authorize returns nil if the given user has access to the given topic using the desired |  | ||||||
| // permission. The user param may be nil to signal an anonymous user. |  | ||||||
| func (a *SQLiteManager) Authorize(user *User, topic string, perm Permission) error { |  | ||||||
| 	if user != nil && user.Role == RoleAdmin { |  | ||||||
| 		return nil // Admin can do everything |  | ||||||
| 	} |  | ||||||
| 	username := Everyone |  | ||||||
| 	if user != nil { |  | ||||||
| 		username = user.Name |  | ||||||
| 	} |  | ||||||
| 	// Select the read/write permissions for this user/topic combo. The query may return two |  | ||||||
| 	// rows (one for everyone, and one for the user), but prioritizes the user. The value for |  | ||||||
| 	// user.Name may be empty (= everyone). |  | ||||||
| 	rows, err := a.db.Query(selectTopicPermsQuery, username, topic) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	defer rows.Close() |  | ||||||
| 	if !rows.Next() { |  | ||||||
| 		return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) |  | ||||||
| 	} |  | ||||||
| 	var read, write bool |  | ||||||
| 	if err := rows.Scan(&read, &write); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} else if err := rows.Err(); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return a.resolvePerms(read, write, perm) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) resolvePerms(read, write bool, perm Permission) error { |  | ||||||
| 	if perm == PermissionRead && read { |  | ||||||
| 		return nil |  | ||||||
| 	} else if perm == PermissionWrite && write { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 	return ErrUnauthorized |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // AddUser adds a user with the given username, password and role. The password should be hashed |  | ||||||
| // before it is stored in a persistence layer. |  | ||||||
| func (a *SQLiteManager) AddUser(username, password string, role Role) error { |  | ||||||
| 	if !AllowedUsername(username) || !AllowedRole(role) { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} |  | ||||||
| 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // RemoveUser deletes the user with the given username. The function returns nil on success, even |  | ||||||
| // if the user did not exist in the first place. |  | ||||||
| func (a *SQLiteManager) RemoveUser(username string) error { |  | ||||||
| 	if !AllowedUsername(username) { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(deleteUserTokensQuery, username); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(deleteUserQuery, username); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Users returns a list of users. It always also returns the Everyone user ("*"). |  | ||||||
| func (a *SQLiteManager) Users() ([]*User, error) { |  | ||||||
| 	rows, err := a.db.Query(selectUsernamesQuery) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	defer rows.Close() |  | ||||||
| 	usernames := make([]string, 0) |  | ||||||
| 	for rows.Next() { |  | ||||||
| 		var username string |  | ||||||
| 		if err := rows.Scan(&username); err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} else if err := rows.Err(); err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 		usernames = append(usernames, username) |  | ||||||
| 	} |  | ||||||
| 	rows.Close() |  | ||||||
| 	users := make([]*User, 0) |  | ||||||
| 	for _, username := range usernames { |  | ||||||
| 		user, err := a.User(username) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 		users = append(users, user) |  | ||||||
| 	} |  | ||||||
| 	everyone, err := a.everyoneUser() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	users = append(users, everyone) |  | ||||||
| 	return users, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // User returns the user with the given username if it exists, or ErrNotFound otherwise. |  | ||||||
| // You may also pass Everyone to retrieve the anonymous user and its Grant list. |  | ||||||
| func (a *SQLiteManager) User(username string) (*User, error) { |  | ||||||
| 	if username == Everyone { |  | ||||||
| 		return a.everyoneUser() |  | ||||||
| 	} |  | ||||||
| 	rows, err := a.db.Query(selectUserByNameQuery, username) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	return a.readUser(rows) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) userByToken(token string) (*User, error) { |  | ||||||
| 	rows, err := a.db.Query(selectUserByTokenQuery, token) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	return a.readUser(rows) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) readUser(rows *sql.Rows) (*User, error) { |  | ||||||
| 	defer rows.Close() |  | ||||||
| 	var username, hash, role string |  | ||||||
| 	var settings, planCode sql.NullString |  | ||||||
| 	var messages, emails int64 |  | ||||||
| 	var messagesLimit, emailsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit sql.NullInt64 |  | ||||||
| 	if !rows.Next() { |  | ||||||
| 		return nil, ErrNotFound |  | ||||||
| 	} |  | ||||||
| 	if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &planCode, &messagesLimit, &emailsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit); err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} else if err := rows.Err(); err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	grants, err := a.readGrants(username) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	user := &User{ |  | ||||||
| 		Name:   username, |  | ||||||
| 		Hash:   hash, |  | ||||||
| 		Role:   Role(role), |  | ||||||
| 		Grants: grants, |  | ||||||
| 		Stats: &Stats{ |  | ||||||
| 			Messages: messages, |  | ||||||
| 			Emails:   emails, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 	if settings.Valid { |  | ||||||
| 		user.Prefs = &Prefs{} |  | ||||||
| 		if err := json.Unmarshal([]byte(settings.String), user.Prefs); err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	if planCode.Valid { |  | ||||||
| 		user.Plan = &Plan{ |  | ||||||
| 			Code:                     planCode.String, |  | ||||||
| 			Upgradable:               true, // FIXME |  | ||||||
| 			MessagesLimit:            messagesLimit.Int64, |  | ||||||
| 			EmailsLimit:              emailsLimit.Int64, |  | ||||||
| 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64, |  | ||||||
| 			AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return user, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) everyoneUser() (*User, error) { |  | ||||||
| 	grants, err := a.readGrants(Everyone) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	return &User{ |  | ||||||
| 		Name:   Everyone, |  | ||||||
| 		Hash:   "", |  | ||||||
| 		Role:   RoleAnonymous, |  | ||||||
| 		Grants: grants, |  | ||||||
| 	}, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (a *SQLiteManager) readGrants(username string) ([]Grant, error) { |  | ||||||
| 	rows, err := a.db.Query(selectUserAccessQuery, username) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	defer rows.Close() |  | ||||||
| 	grants := make([]Grant, 0) |  | ||||||
| 	for rows.Next() { |  | ||||||
| 		var topic string |  | ||||||
| 		var read, write bool |  | ||||||
| 		if err := rows.Scan(&topic, &read, &write); err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} else if err := rows.Err(); err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 		grants = append(grants, Grant{ |  | ||||||
| 			TopicPattern: fromSQLWildcard(topic), |  | ||||||
| 			AllowRead:    read, |  | ||||||
| 			AllowWrite:   write, |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| 	return grants, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // ChangePassword changes a user's password |  | ||||||
| func (a *SQLiteManager) ChangePassword(username, password string) error { |  | ||||||
| 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, |  | ||||||
| // all existing access control entries (Grant) are removed, since they are no longer needed. |  | ||||||
| func (a *SQLiteManager) ChangeRole(username string, role Role) error { |  | ||||||
| 	if !AllowedUsername(username) || !AllowedRole(role) { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if role == RoleAdmin { |  | ||||||
| 		if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // AllowAccess adds or updates an entry in th access control list for a specific user. It controls |  | ||||||
| // read/write access to a topic. The parameter topicPattern may include wildcards (*). |  | ||||||
| func (a *SQLiteManager) AllowAccess(username string, topicPattern string, read bool, write bool) error { |  | ||||||
| 	if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} |  | ||||||
| 	if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is |  | ||||||
| // empty) for an entire user. The parameter topicPattern may include wildcards (*). |  | ||||||
| func (a *SQLiteManager) ResetAccess(username string, topicPattern string) error { |  | ||||||
| 	if !AllowedUsername(username) && username != Everyone && username != "" { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { |  | ||||||
| 		return ErrInvalidArgument |  | ||||||
| 	} |  | ||||||
| 	if username == "" && topicPattern == "" { |  | ||||||
| 		_, err := a.db.Exec(deleteAllAccessQuery, username) |  | ||||||
| 		return err |  | ||||||
| 	} else if topicPattern == "" { |  | ||||||
| 		_, err := a.db.Exec(deleteUserAccessQuery, username) |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) |  | ||||||
| 	return err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // DefaultAccess returns the default read/write access if no access control entry matches |  | ||||||
| func (a *SQLiteManager) DefaultAccess() (read bool, write bool) { |  | ||||||
| 	return a.defaultRead, a.defaultWrite |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func toSQLWildcard(s string) string { |  | ||||||
| 	return strings.ReplaceAll(s, "*", "%") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func fromSQLWildcard(s string) string { |  | ||||||
| 	return strings.ReplaceAll(s, "%", "*") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func setupAuthDB(db *sql.DB) error { |  | ||||||
| 	// If 'schemaVersion' table does not exist, this must be a new database |  | ||||||
| 	rowsSV, err := db.Query(selectSchemaVersionQuery) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return setupNewAuthDB(db) |  | ||||||
| 	} |  | ||||||
| 	defer rowsSV.Close() |  | ||||||
| 
 |  | ||||||
| 	// If 'schemaVersion' table exists, read version and potentially upgrade |  | ||||||
| 	schemaVersion := 0 |  | ||||||
| 	if !rowsSV.Next() { |  | ||||||
| 		return errors.New("cannot determine schema version: database file may be corrupt") |  | ||||||
| 	} |  | ||||||
| 	if err := rowsSV.Scan(&schemaVersion); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	rowsSV.Close() |  | ||||||
| 
 |  | ||||||
| 	// Do migrations |  | ||||||
| 	if schemaVersion == currentSchemaVersion { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func setupNewAuthDB(db *sql.DB) error { |  | ||||||
| 	if _, err := db.Exec(createAuthTablesQueries); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
							
								
								
									
										177
									
								
								user/types.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								user/types.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,177 @@ | ||||||
|  | // Package user deals with authentication and authorization against topics | ||||||
|  | package user | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"regexp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Manager is a generic interface to implement password and token based authentication and authorization | ||||||
|  | type Manager interface { | ||||||
|  | 	// Authenticate checks username and password and returns a user if correct. The method | ||||||
|  | 	// returns in constant-ish time, regardless of whether the user exists or the password is | ||||||
|  | 	// correct or incorrect. | ||||||
|  | 	Authenticate(username, password string) (*User, error) | ||||||
|  | 
 | ||||||
|  | 	AuthenticateToken(token string) (*User, error) | ||||||
|  | 	CreateToken(user *User) (*Token, error) | ||||||
|  | 	ExtendToken(user *User) (*Token, error) | ||||||
|  | 	RemoveToken(user *User) error | ||||||
|  | 	RemoveExpiredTokens() error | ||||||
|  | 	ChangeSettings(user *User) error | ||||||
|  | 	EnqueueStats(user *User) | ||||||
|  | 
 | ||||||
|  | 	// Authorize returns nil if the given user has access to the given topic using the desired | ||||||
|  | 	// permission. The user param may be nil to signal an anonymous user. | ||||||
|  | 	Authorize(user *User, topic string, perm Permission) error | ||||||
|  | 
 | ||||||
|  | 	// AddUser adds a user with the given username, password and role. The password should be hashed | ||||||
|  | 	// before it is stored in a persistence layer. | ||||||
|  | 	AddUser(username, password string, role Role) error | ||||||
|  | 
 | ||||||
|  | 	// RemoveUser deletes the user with the given username. The function returns nil on success, even | ||||||
|  | 	// if the user did not exist in the first place. | ||||||
|  | 	RemoveUser(username string) error | ||||||
|  | 
 | ||||||
|  | 	// Users returns a list of users. It always also returns the Everyone user ("*"). | ||||||
|  | 	Users() ([]*User, error) | ||||||
|  | 
 | ||||||
|  | 	// User returns the user with the given username if it exists, or ErrNotFound otherwise. | ||||||
|  | 	// You may also pass Everyone to retrieve the anonymous user and its Grant list. | ||||||
|  | 	User(username string) (*User, error) | ||||||
|  | 
 | ||||||
|  | 	// ChangePassword changes a user's password | ||||||
|  | 	ChangePassword(username, password string) error | ||||||
|  | 
 | ||||||
|  | 	// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, | ||||||
|  | 	// all existing access control entries (Grant) are removed, since they are no longer needed. | ||||||
|  | 	ChangeRole(username string, role Role) error | ||||||
|  | 
 | ||||||
|  | 	// AllowAccess adds or updates an entry in th access control list for a specific user. It controls | ||||||
|  | 	// read/write access to a topic. The parameter topicPattern may include wildcards (*). | ||||||
|  | 	AllowAccess(username string, topicPattern string, read bool, write bool) error | ||||||
|  | 
 | ||||||
|  | 	// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is | ||||||
|  | 	// empty) for an entire user. The parameter topicPattern may include wildcards (*). | ||||||
|  | 	ResetAccess(username string, topicPattern string) error | ||||||
|  | 
 | ||||||
|  | 	// DefaultAccess returns the default read/write access if no access control entry matches | ||||||
|  | 	DefaultAccess() (read bool, write bool) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // User is a struct that represents a user | ||||||
|  | type User struct { | ||||||
|  | 	Name   string | ||||||
|  | 	Hash   string // password hash (bcrypt) | ||||||
|  | 	Token  string // Only set if token was used to log in | ||||||
|  | 	Role   Role | ||||||
|  | 	Grants []Grant | ||||||
|  | 	Prefs  *Prefs | ||||||
|  | 	Plan   *Plan | ||||||
|  | 	Stats  *Stats | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Token struct { | ||||||
|  | 	Value   string | ||||||
|  | 	Expires int64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Prefs struct { | ||||||
|  | 	Language      string             `json:"language,omitempty"` | ||||||
|  | 	Notification  *NotificationPrefs `json:"notification,omitempty"` | ||||||
|  | 	Subscriptions []*Subscription    `json:"subscriptions,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type PlanCode string | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	PlanUnlimited = PlanCode("unlimited") | ||||||
|  | 	PlanDefault   = PlanCode("default") | ||||||
|  | 	PlanNone      = PlanCode("none") | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type Plan struct { | ||||||
|  | 	Code                     string `json:"name"` | ||||||
|  | 	Upgradable               bool   `json:"upgradable"` | ||||||
|  | 	MessagesLimit            int64  `json:"messages_limit"` | ||||||
|  | 	EmailsLimit              int64  `json:"emails_limit"` | ||||||
|  | 	AttachmentFileSizeLimit  int64  `json:"attachment_file_size_limit"` | ||||||
|  | 	AttachmentTotalSizeLimit int64  `json:"attachment_total_size_limit"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Subscription struct { | ||||||
|  | 	ID          string `json:"id"` | ||||||
|  | 	BaseURL     string `json:"base_url"` | ||||||
|  | 	Topic       string `json:"topic"` | ||||||
|  | 	DisplayName string `json:"display_name"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type NotificationPrefs struct { | ||||||
|  | 	Sound       string `json:"sound,omitempty"` | ||||||
|  | 	MinPriority int    `json:"min_priority,omitempty"` | ||||||
|  | 	DeleteAfter int    `json:"delete_after,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Stats struct { | ||||||
|  | 	Messages int64 | ||||||
|  | 	Emails   int64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Grant is a struct that represents an access control entry to a topic | ||||||
|  | type Grant struct { | ||||||
|  | 	TopicPattern string // May include wildcard (*) | ||||||
|  | 	AllowRead    bool | ||||||
|  | 	AllowWrite   bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Permission represents a read or write permission to a topic | ||||||
|  | type Permission int | ||||||
|  | 
 | ||||||
|  | // Permissions to a topic | ||||||
|  | const ( | ||||||
|  | 	PermissionRead  = Permission(1) | ||||||
|  | 	PermissionWrite = Permission(2) | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Role represents a user's role, either admin or regular user | ||||||
|  | type Role string | ||||||
|  | 
 | ||||||
|  | // User roles | ||||||
|  | const ( | ||||||
|  | 	RoleAdmin     = Role("admin") | ||||||
|  | 	RoleUser      = Role("user") | ||||||
|  | 	RoleAnonymous = Role("anonymous") | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Everyone is a special username representing anonymous users | ||||||
|  | const ( | ||||||
|  | 	Everyone = "*" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	allowedUsernameRegex     = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`)     // Does not include Everyone (*) | ||||||
|  | 	allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // AllowedRole returns true if the given role can be used for new users | ||||||
|  | func AllowedRole(role Role) bool { | ||||||
|  | 	return role == RoleUser || role == RoleAdmin | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AllowedUsername returns true if the given username is valid | ||||||
|  | func AllowedUsername(username string) bool { | ||||||
|  | 	return allowedUsernameRegex.MatchString(username) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) | ||||||
|  | func AllowedTopicPattern(username string) bool { | ||||||
|  | 	return allowedTopicPatternRegex.MatchString(username) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Error constants used by the package | ||||||
|  | var ( | ||||||
|  | 	ErrUnauthenticated = errors.New("unauthenticated") | ||||||
|  | 	ErrUnauthorized    = errors.New("unauthorized") | ||||||
|  | 	ErrInvalidArgument = errors.New("invalid argument") | ||||||
|  | 	ErrNotFound        = errors.New("not found") | ||||||
|  | ) | ||||||
|  | @ -175,6 +175,25 @@ class AccountApi { | ||||||
|         return subscription; |         return subscription; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     async updateSubscription(remoteId, payload) { | ||||||
|  |         const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); | ||||||
|  |         const body = JSON.stringify(payload); | ||||||
|  |         console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); | ||||||
|  |         const response = await fetch(url, { | ||||||
|  |             method: "PATCH", | ||||||
|  |             headers: maybeWithBearerAuth({}, session.token()), | ||||||
|  |             body: body | ||||||
|  |         }); | ||||||
|  |         if (response.status === 401 || response.status === 403) { | ||||||
|  |             throw new UnauthorizedError(); | ||||||
|  |         } else if (response.status !== 200) { | ||||||
|  |             throw new Error(`Unexpected server response ${response.status}`); | ||||||
|  |         } | ||||||
|  |         const subscription = await response.json(); | ||||||
|  |         console.log(`[AccountApi] Subscription`, subscription); | ||||||
|  |         return subscription; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     async deleteSubscription(remoteId) { |     async deleteSubscription(remoteId) { | ||||||
|         const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); |         const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); | ||||||
|         console.log(`[AccountApi] Removing user subscription ${url}`); |         console.log(`[AccountApi] Removing user subscription ${url}`); | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
|  | import routes from "../components/routes"; | ||||||
|  | 
 | ||||||
| class Session { | class Session { | ||||||
|     store(username, token) { |     store(username, token) { | ||||||
|         localStorage.setItem("user", username); |         localStorage.setItem("user", username); | ||||||
|  | @ -9,6 +11,11 @@ class Session { | ||||||
|         localStorage.removeItem("token"); |         localStorage.removeItem("token"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     resetAndRedirect(url) { | ||||||
|  |         this.reset(); | ||||||
|  |         window.location.href = url; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     exists() { |     exists() { | ||||||
|         return this.username() && this.token(); |         return this.username() && this.token(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -36,12 +36,15 @@ class SubscriptionManager { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async syncFromRemote(remoteSubscriptions) { |     async syncFromRemote(remoteSubscriptions) { | ||||||
|  |         console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); | ||||||
|  | 
 | ||||||
|         // Add remote subscriptions
 |         // Add remote subscriptions
 | ||||||
|         let remoteIds = []; |         let remoteIds = []; | ||||||
|         for (let i = 0; i < remoteSubscriptions.length; i++) { |         for (let i = 0; i < remoteSubscriptions.length; i++) { | ||||||
|             const remote = remoteSubscriptions[i]; |             const remote = remoteSubscriptions[i]; | ||||||
|             const local = await this.add(remote.base_url, remote.topic); |             const local = await this.add(remote.base_url, remote.topic); | ||||||
|             await this.setRemoteId(local.id, remote.id); |             await this.setRemoteId(local.id, remote.id); | ||||||
|  |             await this.setDisplayName(local.id, remote.display_name); | ||||||
|             remoteIds.push(remote.id); |             remoteIds.push(remote.id); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -49,7 +52,7 @@ class SubscriptionManager { | ||||||
|         const localSubscriptions = await db.subscriptions.toArray(); |         const localSubscriptions = await db.subscriptions.toArray(); | ||||||
|         for (let i = 0; i < localSubscriptions.length; i++) { |         for (let i = 0; i < localSubscriptions.length; i++) { | ||||||
|             const local = localSubscriptions[i]; |             const local = localSubscriptions[i]; | ||||||
|             if (local.remoteId && !remoteIds.includes(local.remoteId)) { |             if (!local.remoteId || !remoteIds.includes(local.remoteId)) { | ||||||
|                 await this.remove(local.id); |                 await this.remove(local.id); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -204,7 +204,7 @@ const SettingsIcons = (props) => { | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}> |             <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}> | ||||||
|                 {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>} |                 {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>} | ||||||
|             </IconButton> |             </IconButton> | ||||||
|             <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}> |             <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}> | ||||||
|  |  | ||||||
|  | @ -319,6 +319,7 @@ const UserTable = (props) => { | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|     return ( |     return ( | ||||||
|  |         <div> | ||||||
|             <Table size="small" aria-label={t("prefs_users_table")}> |             <Table size="small" aria-label={t("prefs_users_table")}> | ||||||
|                 <TableHead> |                 <TableHead> | ||||||
|                     <TableRow> |                     <TableRow> | ||||||
|  | @ -333,13 +334,16 @@ const UserTable = (props) => { | ||||||
|                             key={user.baseUrl} |                             key={user.baseUrl} | ||||||
|                             sx={{'&:last-child td, &:last-child th': {border: 0}}} |                             sx={{'&:last-child td, &:last-child th': {border: 0}}} | ||||||
|                         > |                         > | ||||||
|                         <TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell> |                             <TableCell component="th" scope="row" sx={{paddingLeft: 0}} | ||||||
|  |                                        aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell> | ||||||
|                             <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell> |                             <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell> | ||||||
|                             <TableCell align="right"> |                             <TableCell align="right"> | ||||||
|                             <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> |                                 <IconButton onClick={() => handleEditClick(user)} | ||||||
|  |                                             aria-label={t("prefs_users_edit_button")}> | ||||||
|                                     <EditIcon/> |                                     <EditIcon/> | ||||||
|                                 </IconButton> |                                 </IconButton> | ||||||
|                             <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> |                                 <IconButton onClick={() => handleDeleteClick(user)} | ||||||
|  |                                             aria-label={t("prefs_users_delete_button")}> | ||||||
|                                     <CloseIcon/> |                                     <CloseIcon/> | ||||||
|                                 </IconButton> |                                 </IconButton> | ||||||
|                             </TableCell> |                             </TableCell> | ||||||
|  | @ -355,6 +359,12 @@ const UserTable = (props) => { | ||||||
|                     onSubmit={handleDialogSubmit} |                     onSubmit={handleDialogSubmit} | ||||||
|                 /> |                 /> | ||||||
|             </Table> |             </Table> | ||||||
|  |             {session.exists() && | ||||||
|  |                 <Typography> | ||||||
|  |                     xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | ||||||
|  |                 </Typography> | ||||||
|  |             } | ||||||
|  |         </div> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -672,8 +682,7 @@ const maybeUpdateAccountSettings = async (payload) => { | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|         console.log(`[Preferences] Error updating account settings`, e); |         console.log(`[Preferences] Error updating account settings`, e); | ||||||
|         if ((e instanceof UnauthorizedError)) { |         if ((e instanceof UnauthorizedError)) { | ||||||
|             session.reset(); |             session.resetAndRedirect(routes.login); | ||||||
|             window.location.href = routes.login; |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -15,6 +15,9 @@ import subscriptionManager from "../app/SubscriptionManager"; | ||||||
| import poller from "../app/Poller"; | import poller from "../app/Poller"; | ||||||
| import DialogFooter from "./DialogFooter"; | import DialogFooter from "./DialogFooter"; | ||||||
| import {useTranslation} from "react-i18next"; | import {useTranslation} from "react-i18next"; | ||||||
|  | import accountApi, {UnauthorizedError} from "../app/AccountApi"; | ||||||
|  | import session from "../app/Session"; | ||||||
|  | import routes from "./routes"; | ||||||
| 
 | 
 | ||||||
| const SubscriptionSettingsDialog = (props) => { | const SubscriptionSettingsDialog = (props) => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|  | @ -23,6 +26,17 @@ const SubscriptionSettingsDialog = (props) => { | ||||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); |     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||||
|     const handleSave = async () => { |     const handleSave = async () => { | ||||||
|         await subscriptionManager.setDisplayName(subscription.id, displayName); |         await subscriptionManager.setDisplayName(subscription.id, displayName); | ||||||
|  |         if (session.exists() && subscription.remoteId) { | ||||||
|  |             try { | ||||||
|  |                 console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); | ||||||
|  |                 await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName }); | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); | ||||||
|  |                 if ((e instanceof UnauthorizedError)) { | ||||||
|  |                     session.resetAndRedirect(routes.login); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|         props.onClose(); |         props.onClose(); | ||||||
|     } |     } | ||||||
|     return ( |     return ( | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue