瀏覽代碼

Display name sync

binwiederhier 3 年之前
父節點
當前提交
2fb4bd4975

+ 2 - 0
server/server.go

@@ -355,6 +355,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleAccountSettingsChange(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
 		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) {
 		return s.handleAccountSubscriptionDelete(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {

+ 59 - 10
server/server_account.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"heckel.io/ntfy/user"
 	"heckel.io/ntfy/util"
+	"io"
 	"net/http"
 )
 
@@ -244,40 +245,75 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
 	if v.user == nil {
 		return errors.New("no user")
 	}
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
-	body, err := util.Peek(r.Body, 4096)               // FIXME
+	newSubscription, err := readJSONBody[user.Subscription](r.Body)
 	if err != nil {
 		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 {
 		v.user.Prefs = &user.Prefs{}
 	}
 	newSubscription.ID = "" // Client cannot set ID
 	for _, subscription := range v.user.Prefs.Subscriptions {
 		if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
-			newSubscription = *subscription
+			newSubscription = subscription
 			break
 		}
 	}
 	if newSubscription.ID == "" {
 		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 {
 			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 {
 		return err
 	}
 	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 {
 	if v.user == nil {
 		return errors.New("no user")
@@ -306,3 +342,16 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
 	}
 	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
+}

+ 543 - 127
user/manager.go

@@ -1,176 +1,592 @@
-// Package auth deals with authentication and authorization against topics
 package user
 
 import (
+	"database/sql"
+	"encoding/json"
 	"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
-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)
+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
+	`
+)
 
-	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)
+// 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 = ?)`
+)
 
-	// 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
+// Schema management queries
+const (
+	currentSchemaVersion     = 1
+	insertSchemaVersion      = `INSERT INTO schemaVersion VALUES (1, ?)`
+	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
+)
 
-	// 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
+// 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
+}
 
-	// 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
+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
+}
 
-	// Users returns a list of users. It always also returns the Everyone user ("*").
-	Users() ([]*User, error)
+// 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
+}
 
-	// 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)
+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
+}
 
-	// ChangePassword changes a user's password
-	ChangePassword(username, password string) error
+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
+}
 
-	// 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
+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
+}
 
-	// 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
+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
+}
 
-	// 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
+func (a *SQLiteManager) RemoveExpiredTokens() error {
+	if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
+		return err
+	}
+	return nil
+}
 
-	// DefaultAccess returns the default read/write access if no access control entry matches
-	DefaultAccess() (read bool, write bool)
+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
 }
 
-// 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
+func (a *SQLiteManager) EnqueueStats(user *User) {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	a.statsQueue[user.Name] = user
 }
 
-type Token struct {
-	Value   string
-	Expires int64
+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())
+		}
+	}
 }
 
-type Prefs struct {
-	Language      string             `json:"language,omitempty"`
-	Notification  *NotificationPrefs `json:"notification,omitempty"`
-	Subscriptions []*Subscription    `json:"subscriptions,omitempty"`
+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()
 }
 
-type PlanCode string
+// 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)
+}
 
-const (
-	PlanUnlimited = PlanCode("unlimited")
-	PlanDefault   = PlanCode("default")
-	PlanNone      = PlanCode("none")
-)
+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
+}
 
-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"`
+// 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
 }
 
-type Subscription struct {
-	ID      string `json:"id"`
-	BaseURL string `json:"base_url"`
-	Topic   string `json:"topic"`
+// 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
 }
 
-type NotificationPrefs struct {
-	Sound       string `json:"sound,omitempty"`
-	MinPriority int    `json:"min_priority,omitempty"`
-	DeleteAfter int    `json:"delete_after,omitempty"`
+// 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
 }
 
-type Stats struct {
-	Messages int64
-	Emails   int64
+// 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)
 }
 
-// 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
+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)
 }
 
-// Permission represents a read or write permission to a topic
-type Permission int
+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
+}
 
-// Permissions to a topic
-const (
-	PermissionRead  = Permission(1)
-	PermissionWrite = Permission(2)
-)
+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
+}
 
-// Role represents a user's role, either admin or regular user
-type Role string
+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
+}
 
-// User roles
-const (
-	RoleAdmin     = Role("admin")
-	RoleUser      = Role("user")
-	RoleAnonymous = Role("anonymous")
-)
+// 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
+}
 
-// Everyone is a special username representing anonymous users
-const (
-	Everyone = "*"
-)
+// 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
+}
 
-var (
-	allowedUsernameRegex     = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`)     // Does not include Everyone (*)
-	allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
-)
+// 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
+}
 
-// AllowedRole returns true if the given role can be used for new users
-func AllowedRole(role Role) bool {
-	return role == RoleUser || role == RoleAdmin
+// 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
 }
 
-// AllowedUsername returns true if the given username is valid
-func AllowedUsername(username string) bool {
-	return allowedUsernameRegex.MatchString(username)
+// 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
 }
 
-// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
-func AllowedTopicPattern(username string) bool {
-	return allowedTopicPatternRegex.MatchString(username)
+func toSQLWildcard(s string) string {
+	return strings.ReplaceAll(s, "*", "%")
 }
 
-// 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")
-)
+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
+}

+ 0 - 592
user/manager_sqlite.go

@@ -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
-}

+ 0 - 0
user/manager_sqlite_test.go → user/manager_test.go


+ 177 - 0
user/types.go

@@ -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")
+)

+ 19 - 0
web/src/app/AccountApi.js

@@ -175,6 +175,25 @@ class AccountApi {
         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) {
         const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId);
         console.log(`[AccountApi] Removing user subscription ${url}`);

+ 7 - 0
web/src/app/Session.js

@@ -1,3 +1,5 @@
+import routes from "../components/routes";
+
 class Session {
     store(username, token) {
         localStorage.setItem("user", username);
@@ -9,6 +11,11 @@ class Session {
         localStorage.removeItem("token");
     }
 
+    resetAndRedirect(url) {
+        this.reset();
+        window.location.href = url;
+    }
+
     exists() {
         return this.username() && this.token();
     }

+ 4 - 1
web/src/app/SubscriptionManager.js

@@ -36,12 +36,15 @@ class SubscriptionManager {
     }
 
     async syncFromRemote(remoteSubscriptions) {
+        console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
+
         // Add remote subscriptions
         let remoteIds = [];
         for (let i = 0; i < remoteSubscriptions.length; i++) {
             const remote = remoteSubscriptions[i];
             const local = await this.add(remote.base_url, remote.topic);
             await this.setRemoteId(local.id, remote.id);
+            await this.setDisplayName(local.id, remote.display_name);
             remoteIds.push(remote.id);
         }
 
@@ -49,7 +52,7 @@ class SubscriptionManager {
         const localSubscriptions = await db.subscriptions.toArray();
         for (let i = 0; i < localSubscriptions.length; i++) {
             const local = localSubscriptions[i];
-            if (local.remoteId && !remoteIds.includes(local.remoteId)) {
+            if (!local.remoteId || !remoteIds.includes(local.remoteId)) {
                 await this.remove(local.id);
             }
         }

+ 1 - 1
web/src/components/ActionBar.js

@@ -204,7 +204,7 @@ const SettingsIcons = (props) => {
 
     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/>}
             </IconButton>
             <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>

+ 46 - 37
web/src/components/Preferences.js

@@ -319,42 +319,52 @@ const UserTable = (props) => {
         }
     };
     return (
-        <Table size="small" aria-label={t("prefs_users_table")}>
-            <TableHead>
-                <TableRow>
-                    <TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
-                    <TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
-                    <TableCell/>
-                </TableRow>
-            </TableHead>
-            <TableBody>
-                {props.users?.map(user => (
-                    <TableRow
-                        key={user.baseUrl}
-                        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 aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
-                        <TableCell align="right">
-                            <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
-                                <EditIcon/>
-                            </IconButton>
-                            <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
-                                <CloseIcon />
-                            </IconButton>
-                        </TableCell>
+        <div>
+            <Table size="small" aria-label={t("prefs_users_table")}>
+                <TableHead>
+                    <TableRow>
+                        <TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
+                        <TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
+                        <TableCell/>
                     </TableRow>
-                ))}
-            </TableBody>
-            <UserDialog
-                key={`userEditDialog${dialogKey}`}
-                open={dialogOpen}
-                user={dialogUser}
-                users={props.users}
-                onCancel={handleDialogCancel}
-                onSubmit={handleDialogSubmit}
-            />
-        </Table>
+                </TableHead>
+                <TableBody>
+                    {props.users?.map(user => (
+                        <TableRow
+                            key={user.baseUrl}
+                            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 aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
+                            <TableCell align="right">
+                                <IconButton onClick={() => handleEditClick(user)}
+                                            aria-label={t("prefs_users_edit_button")}>
+                                    <EditIcon/>
+                                </IconButton>
+                                <IconButton onClick={() => handleDeleteClick(user)}
+                                            aria-label={t("prefs_users_delete_button")}>
+                                    <CloseIcon/>
+                                </IconButton>
+                            </TableCell>
+                        </TableRow>
+                    ))}
+                </TableBody>
+                <UserDialog
+                    key={`userEditDialog${dialogKey}`}
+                    open={dialogOpen}
+                    user={dialogUser}
+                    users={props.users}
+                    onCancel={handleDialogCancel}
+                    onSubmit={handleDialogSubmit}
+                />
+            </Table>
+            {session.exists() &&
+                <Typography>
+                    xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+                </Typography>
+            }
+        </div>
     );
 };
 
@@ -672,8 +682,7 @@ const maybeUpdateAccountSettings = async (payload) => {
     } catch (e) {
         console.log(`[Preferences] Error updating account settings`, e);
         if ((e instanceof UnauthorizedError)) {
-            session.reset();
-            window.location.href = routes.login;
+            session.resetAndRedirect(routes.login);
         }
     }
 };

+ 14 - 0
web/src/components/SubscriptionSettingsDialog.js

@@ -15,6 +15,9 @@ import subscriptionManager from "../app/SubscriptionManager";
 import poller from "../app/Poller";
 import DialogFooter from "./DialogFooter";
 import {useTranslation} from "react-i18next";
+import accountApi, {UnauthorizedError} from "../app/AccountApi";
+import session from "../app/Session";
+import routes from "./routes";
 
 const SubscriptionSettingsDialog = (props) => {
     const { t } = useTranslation();
@@ -23,6 +26,17 @@ const SubscriptionSettingsDialog = (props) => {
     const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
     const handleSave = async () => {
         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();
     }
     return (