Stuff
This commit is contained in:
		
							parent
							
								
									c35e5b33d1
								
							
						
					
					
						commit
						c2f16f740b
					
				
					 21 changed files with 332 additions and 547 deletions
				
			
		| 
						 | 
					@ -6,8 +6,8 @@ import (
 | 
				
			||||||
	"regexp"
 | 
						"regexp"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Auther is a generic interface to implement password and token based authentication and authorization
 | 
					// Manager is a generic interface to implement password and token based authentication and authorization
 | 
				
			||||||
type Auther interface {
 | 
					type Manager interface {
 | 
				
			||||||
	// 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.
 | 
				
			||||||
| 
						 | 
					@ -21,10 +21,7 @@ type Auther interface {
 | 
				
			||||||
	// 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
 | 
						Authorize(user *User, topic string, perm Permission) error
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Manager is an interface representing user and access management
 | 
					 | 
				
			||||||
type Manager interface {
 | 
					 | 
				
			||||||
	// 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
 | 
						AddUser(username, password string, role Role) error
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,7 +17,7 @@ const (
 | 
				
			||||||
	intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost
 | 
						intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Auther-related queries
 | 
					// Manager-related queries
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
	createAuthTablesQueries = `
 | 
						createAuthTablesQueries = `
 | 
				
			||||||
		BEGIN;
 | 
							BEGIN;
 | 
				
			||||||
| 
						 | 
					@ -105,19 +105,18 @@ const (
 | 
				
			||||||
	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
 | 
						selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list
 | 
					// SQLiteAuthManager is an implementation of Manager and Manager. It stores users and access control list
 | 
				
			||||||
// in a SQLite database.
 | 
					// in a SQLite database.
 | 
				
			||||||
type SQLiteAuth struct {
 | 
					type SQLiteAuthManager struct {
 | 
				
			||||||
	db           *sql.DB
 | 
						db           *sql.DB
 | 
				
			||||||
	defaultRead  bool
 | 
						defaultRead  bool
 | 
				
			||||||
	defaultWrite bool
 | 
						defaultWrite bool
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var _ Auther = (*SQLiteAuth)(nil)
 | 
					var _ Manager = (*SQLiteAuthManager)(nil)
 | 
				
			||||||
var _ Manager = (*SQLiteAuth)(nil)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// NewSQLiteAuth creates a new SQLiteAuth instance
 | 
					// NewSQLiteAuthManager creates a new SQLiteAuthManager instance
 | 
				
			||||||
func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
 | 
					func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteAuthManager, error) {
 | 
				
			||||||
	db, err := sql.Open("sqlite3", filename)
 | 
						db, err := sql.Open("sqlite3", filename)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
| 
						 | 
					@ -125,7 +124,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
 | 
				
			||||||
	if err := setupAuthDB(db); err != nil {
 | 
						if err := setupAuthDB(db); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return &SQLiteAuth{
 | 
						return &SQLiteAuthManager{
 | 
				
			||||||
		db:           db,
 | 
							db:           db,
 | 
				
			||||||
		defaultRead:  defaultRead,
 | 
							defaultRead:  defaultRead,
 | 
				
			||||||
		defaultWrite: defaultWrite,
 | 
							defaultWrite: defaultWrite,
 | 
				
			||||||
| 
						 | 
					@ -135,7 +134,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
 | 
					func (a *SQLiteAuthManager) Authenticate(username, password string) (*User, error) {
 | 
				
			||||||
	if username == Everyone {
 | 
						if username == Everyone {
 | 
				
			||||||
		return nil, ErrUnauthenticated
 | 
							return nil, ErrUnauthenticated
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -151,7 +150,7 @@ func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
 | 
				
			||||||
	return user, nil
 | 
						return user, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
 | 
					func (a *SQLiteAuthManager) AuthenticateToken(token string) (*User, error) {
 | 
				
			||||||
	user, err := a.userByToken(token)
 | 
						user, err := a.userByToken(token)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, ErrUnauthenticated
 | 
							return nil, ErrUnauthenticated
 | 
				
			||||||
| 
						 | 
					@ -160,7 +159,7 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
 | 
				
			||||||
	return user, nil
 | 
						return user, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
 | 
					func (a *SQLiteAuthManager) CreateToken(user *User) (string, error) {
 | 
				
			||||||
	token := util.RandomString(tokenLength)
 | 
						token := util.RandomString(tokenLength)
 | 
				
			||||||
	expires := 1 // FIXME
 | 
						expires := 1 // FIXME
 | 
				
			||||||
	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil {
 | 
						if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil {
 | 
				
			||||||
| 
						 | 
					@ -169,7 +168,7 @@ func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
 | 
				
			||||||
	return token, nil
 | 
						return token, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) RemoveToken(user *User) error {
 | 
					func (a *SQLiteAuthManager) RemoveToken(user *User) error {
 | 
				
			||||||
	if user.Token == "" {
 | 
						if user.Token == "" {
 | 
				
			||||||
		return ErrUnauthorized
 | 
							return ErrUnauthorized
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -179,7 +178,7 @@ func (a *SQLiteAuth) RemoveToken(user *User) error {
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) ChangeSettings(user *User) error {
 | 
					func (a *SQLiteAuthManager) ChangeSettings(user *User) error {
 | 
				
			||||||
	settings, err := json.Marshal(user.Prefs)
 | 
						settings, err := json.Marshal(user.Prefs)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
| 
						 | 
					@ -192,7 +191,7 @@ func (a *SQLiteAuth) ChangeSettings(user *User) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
 | 
					func (a *SQLiteAuthManager) Authorize(user *User, topic string, perm Permission) error {
 | 
				
			||||||
	if user != nil && user.Role == RoleAdmin {
 | 
						if user != nil && user.Role == RoleAdmin {
 | 
				
			||||||
		return nil // Admin can do everything
 | 
							return nil // Admin can do everything
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -220,7 +219,7 @@ func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error
 | 
				
			||||||
	return a.resolvePerms(read, write, perm)
 | 
						return a.resolvePerms(read, write, perm)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
 | 
					func (a *SQLiteAuthManager) resolvePerms(read, write bool, perm Permission) error {
 | 
				
			||||||
	if perm == PermissionRead && read {
 | 
						if perm == PermissionRead && read {
 | 
				
			||||||
		return nil
 | 
							return nil
 | 
				
			||||||
	} else if perm == PermissionWrite && write {
 | 
						} else if perm == PermissionWrite && write {
 | 
				
			||||||
| 
						 | 
					@ -231,7 +230,7 @@ func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
 | 
					func (a *SQLiteAuthManager) AddUser(username, password string, role Role) error {
 | 
				
			||||||
	if !AllowedUsername(username) || !AllowedRole(role) {
 | 
						if !AllowedUsername(username) || !AllowedRole(role) {
 | 
				
			||||||
		return ErrInvalidArgument
 | 
							return ErrInvalidArgument
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -247,7 +246,7 @@ func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) RemoveUser(username string) error {
 | 
					func (a *SQLiteAuthManager) RemoveUser(username string) error {
 | 
				
			||||||
	if !AllowedUsername(username) {
 | 
						if !AllowedUsername(username) {
 | 
				
			||||||
		return ErrInvalidArgument
 | 
							return ErrInvalidArgument
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -261,7 +260,7 @@ func (a *SQLiteAuth) RemoveUser(username string) error {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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 ("*").
 | 
				
			||||||
func (a *SQLiteAuth) Users() ([]*User, error) {
 | 
					func (a *SQLiteAuthManager) Users() ([]*User, error) {
 | 
				
			||||||
	rows, err := a.db.Query(selectUsernamesQuery)
 | 
						rows, err := a.db.Query(selectUsernamesQuery)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
| 
						 | 
					@ -296,7 +295,7 @@ func (a *SQLiteAuth) Users() ([]*User, error) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) User(username string) (*User, error) {
 | 
					func (a *SQLiteAuthManager) User(username string) (*User, error) {
 | 
				
			||||||
	if username == Everyone {
 | 
						if username == Everyone {
 | 
				
			||||||
		return a.everyoneUser()
 | 
							return a.everyoneUser()
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -307,7 +306,7 @@ func (a *SQLiteAuth) User(username string) (*User, error) {
 | 
				
			||||||
	return a.readUser(rows)
 | 
						return a.readUser(rows)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) userByToken(token string) (*User, error) {
 | 
					func (a *SQLiteAuthManager) userByToken(token string) (*User, error) {
 | 
				
			||||||
	rows, err := a.db.Query(selectUserByTokenQuery, token)
 | 
						rows, err := a.db.Query(selectUserByTokenQuery, token)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
| 
						 | 
					@ -315,7 +314,7 @@ func (a *SQLiteAuth) userByToken(token string) (*User, error) {
 | 
				
			||||||
	return a.readUser(rows)
 | 
						return a.readUser(rows)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) {
 | 
					func (a *SQLiteAuthManager) readUser(rows *sql.Rows) (*User, error) {
 | 
				
			||||||
	defer rows.Close()
 | 
						defer rows.Close()
 | 
				
			||||||
	var username, hash, role string
 | 
						var username, hash, role string
 | 
				
			||||||
	var prefs sql.NullString
 | 
						var prefs sql.NullString
 | 
				
			||||||
| 
						 | 
					@ -346,7 +345,7 @@ func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) {
 | 
				
			||||||
	return user, nil
 | 
						return user, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) everyoneUser() (*User, error) {
 | 
					func (a *SQLiteAuthManager) everyoneUser() (*User, error) {
 | 
				
			||||||
	grants, err := a.readGrants(Everyone)
 | 
						grants, err := a.readGrants(Everyone)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
| 
						 | 
					@ -359,7 +358,7 @@ func (a *SQLiteAuth) everyoneUser() (*User, error) {
 | 
				
			||||||
	}, nil
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
 | 
					func (a *SQLiteAuthManager) readGrants(username string) ([]Grant, error) {
 | 
				
			||||||
	rows, err := a.db.Query(selectUserAccessQuery, username)
 | 
						rows, err := a.db.Query(selectUserAccessQuery, username)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
| 
						 | 
					@ -384,7 +383,7 @@ func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// ChangePassword changes a user's password
 | 
					// ChangePassword changes a user's password
 | 
				
			||||||
func (a *SQLiteAuth) ChangePassword(username, password string) error {
 | 
					func (a *SQLiteAuthManager) ChangePassword(username, password string) error {
 | 
				
			||||||
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
 | 
						hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
| 
						 | 
					@ -397,7 +396,7 @@ func (a *SQLiteAuth) ChangePassword(username, password string) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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.
 | 
				
			||||||
func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
 | 
					func (a *SQLiteAuthManager) ChangeRole(username string, role Role) error {
 | 
				
			||||||
	if !AllowedUsername(username) || !AllowedRole(role) {
 | 
						if !AllowedUsername(username) || !AllowedRole(role) {
 | 
				
			||||||
		return ErrInvalidArgument
 | 
							return ErrInvalidArgument
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -414,7 +413,7 @@ func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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 (*).
 | 
				
			||||||
func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error {
 | 
					func (a *SQLiteAuthManager) AllowAccess(username string, topicPattern string, read bool, write bool) error {
 | 
				
			||||||
	if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
 | 
						if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
 | 
				
			||||||
		return ErrInvalidArgument
 | 
							return ErrInvalidArgument
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -426,7 +425,7 @@ func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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 (*).
 | 
				
			||||||
func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
 | 
					func (a *SQLiteAuthManager) ResetAccess(username string, topicPattern string) error {
 | 
				
			||||||
	if !AllowedUsername(username) && username != Everyone && username != "" {
 | 
						if !AllowedUsername(username) && username != Everyone && username != "" {
 | 
				
			||||||
		return ErrInvalidArgument
 | 
							return ErrInvalidArgument
 | 
				
			||||||
	} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
 | 
						} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
 | 
				
			||||||
| 
						 | 
					@ -444,7 +443,7 @@ func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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
 | 
				
			||||||
func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) {
 | 
					func (a *SQLiteAuthManager) DefaultAccess() (read bool, write bool) {
 | 
				
			||||||
	return a.defaultRead, a.defaultWrite
 | 
						return a.defaultRead, a.defaultWrite
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -235,9 +235,9 @@ func TestSQLiteAuth_ChangeRole(t *testing.T) {
 | 
				
			||||||
	require.Equal(t, 0, len(ben.Grants))
 | 
						require.Equal(t, 0, len(ben.Grants))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth {
 | 
					func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuthManager {
 | 
				
			||||||
	filename := filepath.Join(t.TempDir(), "user.db")
 | 
						filename := filepath.Join(t.TempDir(), "user.db")
 | 
				
			||||||
	a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite)
 | 
						a, err := auth.NewSQLiteAuthManager(filename, defaultRead, defaultWrite)
 | 
				
			||||||
	require.Nil(t, err)
 | 
						require.Nil(t, err)
 | 
				
			||||||
	return a
 | 
						return a
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -74,6 +74,8 @@ var flagsServe = append(
 | 
				
			||||||
	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
 | 
						altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
 | 
				
			||||||
	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 | 
						altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 | 
				
			||||||
	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
 | 
						altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
 | 
				
			||||||
 | 
						altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "xxx"}),
 | 
				
			||||||
 | 
						altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "xxx"}),
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var cmdServe = &cli.Command{
 | 
					var cmdServe = &cli.Command{
 | 
				
			||||||
| 
						 | 
					@ -141,6 +143,8 @@ func execServe(c *cli.Context) error {
 | 
				
			||||||
	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 | 
						visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 | 
				
			||||||
	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
 | 
						visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
 | 
				
			||||||
	behindProxy := c.Bool("behind-proxy")
 | 
						behindProxy := c.Bool("behind-proxy")
 | 
				
			||||||
 | 
						enableSignup := c.Bool("enable-signup")
 | 
				
			||||||
 | 
						enableLogin := c.Bool("enable-login")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check values
 | 
						// Check values
 | 
				
			||||||
	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
 | 
						if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
 | 
				
			||||||
| 
						 | 
					@ -268,6 +272,8 @@ func execServe(c *cli.Context) error {
 | 
				
			||||||
	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 | 
						conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 | 
				
			||||||
	conf.BehindProxy = behindProxy
 | 
						conf.BehindProxy = behindProxy
 | 
				
			||||||
	conf.EnableWeb = enableWeb
 | 
						conf.EnableWeb = enableWeb
 | 
				
			||||||
 | 
						conf.EnableSignup = enableSignup
 | 
				
			||||||
 | 
						conf.EnableLogin = enableLogin
 | 
				
			||||||
	conf.Version = c.App.Version
 | 
						conf.Version = c.App.Version
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set up hot-reloading of config
 | 
						// Set up hot-reloading of config
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -278,7 +278,7 @@ func createAuthManager(c *cli.Context) (auth.Manager, error) {
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
 | 
						authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
 | 
				
			||||||
	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
 | 
						authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
 | 
				
			||||||
	return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
 | 
						return auth.NewSQLiteAuthManager(authFile, authDefaultRead, authDefaultWrite)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
 | 
					func readPasswordAndConfirm(c *cli.Context) (string, error) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -100,6 +100,10 @@ type Config struct {
 | 
				
			||||||
	VisitorEmailLimitReplenish           time.Duration
 | 
						VisitorEmailLimitReplenish           time.Duration
 | 
				
			||||||
	BehindProxy                          bool
 | 
						BehindProxy                          bool
 | 
				
			||||||
	EnableWeb                            bool
 | 
						EnableWeb                            bool
 | 
				
			||||||
 | 
						EnableSignup                         bool
 | 
				
			||||||
 | 
						EnableLogin                          bool
 | 
				
			||||||
 | 
						EnableEmailConfirm                   bool
 | 
				
			||||||
 | 
						EnableResetPassword                  bool
 | 
				
			||||||
	Version                              string // injected by App
 | 
						Version                              string // injected by App
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										127
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										127
									
								
								server/server.go
									
										
									
									
									
								
							| 
						 | 
					@ -38,10 +38,7 @@ import (
 | 
				
			||||||
	TODO
 | 
						TODO
 | 
				
			||||||
		expire tokens
 | 
							expire tokens
 | 
				
			||||||
		auto-refresh tokens from UI
 | 
							auto-refresh tokens from UI
 | 
				
			||||||
		pricing page
 | 
					 | 
				
			||||||
		home page
 | 
					 | 
				
			||||||
		reserve topics
 | 
							reserve topics
 | 
				
			||||||
 | 
					 | 
				
			||||||
		Pages:
 | 
							Pages:
 | 
				
			||||||
		- Home
 | 
							- Home
 | 
				
			||||||
		- Signup
 | 
							- Signup
 | 
				
			||||||
| 
						 | 
					@ -52,11 +49,6 @@ import (
 | 
				
			||||||
		- change email
 | 
							- change email
 | 
				
			||||||
		-
 | 
							-
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		Config flags:
 | 
					 | 
				
			||||||
		-
 | 
					 | 
				
			||||||
		- enable-register: true|false
 | 
					 | 
				
			||||||
		- enable-login: true|false
 | 
					 | 
				
			||||||
		- enable-reset-password: true|false
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
| 
						 | 
					@ -74,7 +66,7 @@ type Server struct {
 | 
				
			||||||
	visitors          map[string]*visitor // ip:<ip> or user:<user>
 | 
						visitors          map[string]*visitor // ip:<ip> or user:<user>
 | 
				
			||||||
	firebaseClient    *firebaseClient
 | 
						firebaseClient    *firebaseClient
 | 
				
			||||||
	messages          int64
 | 
						messages          int64
 | 
				
			||||||
	auth              auth.Auther
 | 
						auth              auth.Manager
 | 
				
			||||||
	messageCache      *messageCache
 | 
						messageCache      *messageCache
 | 
				
			||||||
	fileCache         *fileCache
 | 
						fileCache         *fileCache
 | 
				
			||||||
	closeChan         chan bool
 | 
						closeChan         chan bool
 | 
				
			||||||
| 
						 | 
					@ -96,18 +88,19 @@ var (
 | 
				
			||||||
	authPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
 | 
						authPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
 | 
				
			||||||
	publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
 | 
						publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	webConfigPath               = "/config.js"
 | 
						webConfigPath                  = "/config.js"
 | 
				
			||||||
	userStatsPath               = "/user/stats" // FIXME get rid of this in favor of /user/account
 | 
						userStatsPath                  = "/user/stats" // FIXME get rid of this in favor of /user/account
 | 
				
			||||||
	userTokenPath               = "/user/token"
 | 
						accountPath                    = "/v1/account"
 | 
				
			||||||
	userAccountPath             = "/user/account"
 | 
						accountTokenPath               = "/v1/account/token"
 | 
				
			||||||
	userSubscriptionPath        = "/user/subscription"
 | 
						accountSettingsPath            = "/v1/account/settings"
 | 
				
			||||||
	userSubscriptionDeleteRegex = regexp.MustCompile(`^/user/subscription/([-_A-Za-z0-9]{16})$`)
 | 
						accountSubscriptionPath        = "/v1/account/subscription"
 | 
				
			||||||
	matrixPushPath              = "/_matrix/push/v1/notify"
 | 
						accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
 | 
				
			||||||
	staticRegex                 = regexp.MustCompile(`^/static/.+`)
 | 
						matrixPushPath                 = "/_matrix/push/v1/notify"
 | 
				
			||||||
	docsRegex                   = regexp.MustCompile(`^/docs(|/.*)$`)
 | 
						staticRegex                    = regexp.MustCompile(`^/static/.+`)
 | 
				
			||||||
	fileRegex                   = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
 | 
						docsRegex                      = regexp.MustCompile(`^/docs(|/.*)$`)
 | 
				
			||||||
	disallowedTopics            = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
 | 
						fileRegex                      = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
 | 
				
			||||||
	urlRegex                    = regexp.MustCompile(`^https?://`)
 | 
						disallowedTopics               = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
 | 
				
			||||||
 | 
						urlRegex                       = regexp.MustCompile(`^https?://`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	//go:embed site
 | 
						//go:embed site
 | 
				
			||||||
	webFs        embed.FS
 | 
						webFs        embed.FS
 | 
				
			||||||
| 
						 | 
					@ -160,9 +153,9 @@ func New(conf *Config) (*Server, error) {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	var auther auth.Auther
 | 
						var auther auth.Manager
 | 
				
			||||||
	if conf.AuthFile != "" {
 | 
						if conf.AuthFile != "" {
 | 
				
			||||||
		auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
 | 
							auther, err = auth.NewSQLiteAuthManager(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
| 
						 | 
					@ -335,18 +328,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 | 
				
			||||||
		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 | 
							return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 | 
				
			||||||
	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 | 
						} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 | 
				
			||||||
		return s.handleUserStats(w, r, v)
 | 
							return s.handleUserStats(w, r, v)
 | 
				
			||||||
	} else if r.Method == http.MethodGet && r.URL.Path == userTokenPath {
 | 
						} else if r.Method == http.MethodPost && r.URL.Path == accountPath {
 | 
				
			||||||
		return s.handleUserTokenCreate(w, r, v)
 | 
							return s.handleUserAccountCreate(w, r, v)
 | 
				
			||||||
	} else if r.Method == http.MethodDelete && r.URL.Path == userTokenPath {
 | 
						} else if r.Method == http.MethodGet && r.URL.Path == accountTokenPath {
 | 
				
			||||||
		return s.handleUserTokenDelete(w, r, v)
 | 
							return s.handleAccountTokenGet(w, r, v)
 | 
				
			||||||
	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
 | 
						} else if r.Method == http.MethodDelete && r.URL.Path == accountTokenPath {
 | 
				
			||||||
		return s.handleUserAccount(w, r, v)
 | 
							return s.handleAccountTokenDelete(w, r, v)
 | 
				
			||||||
	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath {
 | 
						} else if r.Method == http.MethodGet && r.URL.Path == accountSettingsPath {
 | 
				
			||||||
		return s.handleUserAccountUpdate(w, r, v)
 | 
							return s.handleAccountSettingsGet(w, r, v)
 | 
				
			||||||
	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userSubscriptionPath {
 | 
						} else if r.Method == http.MethodPost && r.URL.Path == accountSettingsPath {
 | 
				
			||||||
		return s.handleUserSubscriptionAdd(w, r, v)
 | 
							return s.handleAccountSettingsPost(w, r, v)
 | 
				
			||||||
	} else if r.Method == http.MethodDelete && userSubscriptionDeleteRegex.MatchString(r.URL.Path) {
 | 
						} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
 | 
				
			||||||
		return s.handleUserSubscriptionDelete(w, r, v)
 | 
							return s.handleAccountSubscriptionAdd(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 {
 | 
						} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
 | 
				
			||||||
		return s.handleMatrixDiscovery(w)
 | 
							return s.handleMatrixDiscovery(w)
 | 
				
			||||||
	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
 | 
						} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
 | 
				
			||||||
| 
						 | 
					@ -441,11 +436,7 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type tokenAuthResponse struct {
 | 
					func (s *Server) handleAccountTokenGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
				
			||||||
	Token string `json:"token"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
					 | 
				
			||||||
	// TODO rate limit
 | 
						// TODO rate limit
 | 
				
			||||||
	if v.user == nil {
 | 
						if v.user == nil {
 | 
				
			||||||
		return errHTTPUnauthorized
 | 
							return errHTTPUnauthorized
 | 
				
			||||||
| 
						 | 
					@ -456,7 +447,7 @@ func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	w.Header().Set("Content-Type", "application/json")
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
						w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
				
			||||||
	response := &tokenAuthResponse{
 | 
						response := &apiAccountTokenResponse{
 | 
				
			||||||
		Token: token,
 | 
							Token: token,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := json.NewEncoder(w).Encode(response); err != nil {
 | 
						if err := json.NewEncoder(w).Encode(response); err != nil {
 | 
				
			||||||
| 
						 | 
					@ -465,7 +456,7 @@ func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
					func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
				
			||||||
	// TODO rate limit
 | 
						// TODO rate limit
 | 
				
			||||||
	if v.user == nil || v.user.Token == "" {
 | 
						if v.user == nil || v.user.Token == "" {
 | 
				
			||||||
		return errHTTPUnauthorized
 | 
							return errHTTPUnauthorized
 | 
				
			||||||
| 
						 | 
					@ -477,24 +468,10 @@ func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type userPlanResponse struct {
 | 
					func (s *Server) handleAccountSettingsGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
				
			||||||
	Id   int    `json:"id"`
 | 
					 | 
				
			||||||
	Name string `json:"name"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type userAccountResponse struct {
 | 
					 | 
				
			||||||
	Username      string                      `json:"username"`
 | 
					 | 
				
			||||||
	Role          string                      `json:"role,omitempty"`
 | 
					 | 
				
			||||||
	Plan          *userPlanResponse           `json:"plan,omitempty"`
 | 
					 | 
				
			||||||
	Language      string                      `json:"language,omitempty"`
 | 
					 | 
				
			||||||
	Notification  *auth.UserNotificationPrefs `json:"notification,omitempty"`
 | 
					 | 
				
			||||||
	Subscriptions []*auth.UserSubscription    `json:"subscriptions,omitempty"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
					 | 
				
			||||||
	w.Header().Set("Content-Type", "application/json")
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
						w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
				
			||||||
	response := &userAccountResponse{}
 | 
						response := &apiAccountSettingsResponse{}
 | 
				
			||||||
	if v.user != nil {
 | 
						if v.user != nil {
 | 
				
			||||||
		response.Username = v.user.Name
 | 
							response.Username = v.user.Name
 | 
				
			||||||
		response.Role = string(v.user.Role)
 | 
							response.Role = string(v.user.Role)
 | 
				
			||||||
| 
						 | 
					@ -510,7 +487,7 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		response = &userAccountResponse{
 | 
							response = &apiAccountSettingsResponse{
 | 
				
			||||||
			Username: auth.Everyone,
 | 
								Username: auth.Everyone,
 | 
				
			||||||
			Role:     string(auth.RoleAnonymous),
 | 
								Role:     string(auth.RoleAnonymous),
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
| 
						 | 
					@ -521,7 +498,31 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
					func (s *Server) handleUserAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
				
			||||||
 | 
						signupAllowed := s.config.EnableSignup
 | 
				
			||||||
 | 
						admin := v.user != nil && v.user.Role == auth.RoleAdmin
 | 
				
			||||||
 | 
						if !signupAllowed && !admin {
 | 
				
			||||||
 | 
							return errHTTPUnauthorized
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						body, err := util.Peek(r.Body, 4096) // FIXME
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer r.Body.Close()
 | 
				
			||||||
 | 
						var newAccount apiAccountCreateRequest
 | 
				
			||||||
 | 
						if err := json.NewDecoder(body).Decode(&newAccount); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
 | 
						w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
				
			||||||
 | 
						// FIXME return something
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) handleAccountSettingsPost(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")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -560,7 +561,7 @@ func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request,
 | 
				
			||||||
	return s.auth.ChangeSettings(v.user)
 | 
						return s.auth.ChangeSettings(v.user)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
 | 
					func (s *Server) handleAccountSubscriptionAdd(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")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -598,13 +599,13 @@ func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Reques
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleUserSubscriptionDelete(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")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	w.Header().Set("Content-Type", "application/json")
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
						w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 | 
				
			||||||
	matches := userSubscriptionDeleteRegex.FindStringSubmatch(r.URL.Path)
 | 
						matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
 | 
				
			||||||
	if len(matches) != 2 {
 | 
						if len(matches) != 2 {
 | 
				
			||||||
		return errHTTPInternalErrorInvalidFilePath // FIXME
 | 
							return errHTTPInternalErrorInvalidFilePath // FIXME
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,10 +28,10 @@ var (
 | 
				
			||||||
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
 | 
					// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
 | 
				
			||||||
type firebaseClient struct {
 | 
					type firebaseClient struct {
 | 
				
			||||||
	sender firebaseSender
 | 
						sender firebaseSender
 | 
				
			||||||
	auther auth.Auther
 | 
						auther auth.Manager
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
 | 
					func newFirebaseClient(sender firebaseSender, auther auth.Manager) *firebaseClient {
 | 
				
			||||||
	return &firebaseClient{
 | 
						return &firebaseClient{
 | 
				
			||||||
		sender: sender,
 | 
							sender: sender,
 | 
				
			||||||
		auther: auther,
 | 
							auther: auther,
 | 
				
			||||||
| 
						 | 
					@ -112,7 +112,7 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
 | 
				
			||||||
//     On Android, this will trigger the app to poll the topic and thereby displaying new messages.
 | 
					//     On Android, this will trigger the app to poll the topic and thereby displaying new messages.
 | 
				
			||||||
//   - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
 | 
					//   - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
 | 
				
			||||||
//     to Firebase here. This is mainly for iOS to support self-hosted servers.
 | 
					//     to Firebase here. This is mainly for iOS to support self-hosted servers.
 | 
				
			||||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
 | 
					func toFirebaseMessage(m *message, auther auth.Manager) (*messaging.Message, error) {
 | 
				
			||||||
	var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
 | 
						var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
 | 
				
			||||||
	var apnsConfig *messaging.APNSConfig
 | 
						var apnsConfig *messaging.APNSConfig
 | 
				
			||||||
	switch m.Event {
 | 
						switch m.Event {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
package server
 | 
					package server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"heckel.io/ntfy/auth"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"net/netip"
 | 
						"net/netip"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
| 
						 | 
					@ -213,3 +214,26 @@ func (q *queryFilter) Pass(msg *message) bool {
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return true
 | 
						return true
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type apiAccountCreateRequest struct {
 | 
				
			||||||
 | 
						Username string `json:"username"`
 | 
				
			||||||
 | 
						Password string `json:"password"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type apiAccountTokenResponse struct {
 | 
				
			||||||
 | 
						Token string `json:"token"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type apiAccountSettingsPlan struct {
 | 
				
			||||||
 | 
						Id   int    `json:"id"`
 | 
				
			||||||
 | 
						Name string `json:"name"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type apiAccountSettingsResponse struct {
 | 
				
			||||||
 | 
						Username      string                      `json:"username"`
 | 
				
			||||||
 | 
						Role          string                      `json:"role,omitempty"`
 | 
				
			||||||
 | 
						Plan          *apiAccountSettingsPlan     `json:"plan,omitempty"`
 | 
				
			||||||
 | 
						Language      string                      `json:"language,omitempty"`
 | 
				
			||||||
 | 
						Notification  *auth.UserNotificationPrefs `json:"notification,omitempty"`
 | 
				
			||||||
 | 
						Subscriptions []*auth.UserSubscription    `json:"subscriptions,omitempty"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
/* general styling */
 | 
					/* general styling */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
html, body {
 | 
					#site {
 | 
				
			||||||
    font-family: 'Roboto', sans-serif;
 | 
					    font-family: 'Roboto', sans-serif;
 | 
				
			||||||
    font-weight: 400;
 | 
					    font-weight: 400;
 | 
				
			||||||
    font-size: 1.1em;
 | 
					    font-size: 1.1em;
 | 
				
			||||||
| 
						 | 
					@ -9,22 +9,16 @@ html, body {
 | 
				
			||||||
    padding: 0;
 | 
					    padding: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
html {
 | 
					#site a, a:visited {
 | 
				
			||||||
    /* prevent scrollbar from repositioning website:
 | 
					 | 
				
			||||||
     * https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
 | 
					 | 
				
			||||||
    overflow-y: scroll;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
a, a:visited {
 | 
					 | 
				
			||||||
    color: #338574;
 | 
					    color: #338574;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
a:hover {
 | 
					#site a:hover {
 | 
				
			||||||
    text-decoration: none;
 | 
					    text-decoration: none;
 | 
				
			||||||
    color: #317f6f;
 | 
					    color: #317f6f;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
h1 {
 | 
					#site h1 {
 | 
				
			||||||
    margin-top: 35px;
 | 
					    margin-top: 35px;
 | 
				
			||||||
    margin-bottom: 30px;
 | 
					    margin-bottom: 30px;
 | 
				
			||||||
    font-size: 2.5em;
 | 
					    font-size: 2.5em;
 | 
				
			||||||
| 
						 | 
					@ -34,7 +28,7 @@ h1 {
 | 
				
			||||||
    color: #666;
 | 
					    color: #666;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
h2 {
 | 
					#site h2 {
 | 
				
			||||||
    margin-top: 30px;
 | 
					    margin-top: 30px;
 | 
				
			||||||
    margin-bottom: 5px;
 | 
					    margin-bottom: 5px;
 | 
				
			||||||
    font-size: 1.8em;
 | 
					    font-size: 1.8em;
 | 
				
			||||||
| 
						 | 
					@ -42,7 +36,7 @@ h2 {
 | 
				
			||||||
    color: #333;
 | 
					    color: #333;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
h3 {
 | 
					#site h3 {
 | 
				
			||||||
    margin-top: 25px;
 | 
					    margin-top: 25px;
 | 
				
			||||||
    margin-bottom: 5px;
 | 
					    margin-bottom: 5px;
 | 
				
			||||||
    font-size: 1.3em;
 | 
					    font-size: 1.3em;
 | 
				
			||||||
| 
						 | 
					@ -50,28 +44,28 @@ h3 {
 | 
				
			||||||
    color: #333;
 | 
					    color: #333;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
p {
 | 
					#site p {
 | 
				
			||||||
    margin-top: 10px;
 | 
					    margin-top: 10px;
 | 
				
			||||||
    margin-bottom: 20px;
 | 
					    margin-bottom: 20px;
 | 
				
			||||||
    line-height: 160%;
 | 
					    line-height: 160%;
 | 
				
			||||||
    font-weight: 400;
 | 
					    font-weight: 400;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
p.smallMarginBottom {
 | 
					#site p.smallMarginBottom {
 | 
				
			||||||
    margin-bottom: 10px;
 | 
					    margin-bottom: 10px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
b {
 | 
					#site b {
 | 
				
			||||||
    font-weight: 500;
 | 
					    font-weight: 500;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
tt {
 | 
					#site tt {
 | 
				
			||||||
    background: #eee;
 | 
					    background: #eee;
 | 
				
			||||||
    padding: 2px 7px;
 | 
					    padding: 2px 7px;
 | 
				
			||||||
    border-radius: 3px;
 | 
					    border-radius: 3px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
code {
 | 
					#site code {
 | 
				
			||||||
    display: block;
 | 
					    display: block;
 | 
				
			||||||
    background: #eee;
 | 
					    background: #eee;
 | 
				
			||||||
    font-family: monospace;
 | 
					    font-family: monospace;
 | 
				
			||||||
| 
						 | 
					@ -85,18 +79,18 @@ code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Main page */
 | 
					/* Main page */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#main {
 | 
					#site #main {
 | 
				
			||||||
    max-width: 900px;
 | 
					    max-width: 900px;
 | 
				
			||||||
    margin: 0 auto 50px auto;
 | 
					    margin: 0 auto 50px auto;
 | 
				
			||||||
    padding: 0 10px;
 | 
					    padding: 0 10px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#error {
 | 
					#site #error {
 | 
				
			||||||
    color: darkred;
 | 
					    color: darkred;
 | 
				
			||||||
    font-style: italic;
 | 
					    font-style: italic;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#ironicCenterTagDontFreakOut {
 | 
					#site #ironicCenterTagDontFreakOut {
 | 
				
			||||||
    color: #666;
 | 
					    color: #666;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -120,22 +114,22 @@ code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Figures */
 | 
					/* Figures */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
figure {
 | 
					#site figure {
 | 
				
			||||||
    text-align: center;
 | 
					    text-align: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
figure img, figure video {
 | 
					#site figure img, figure video {
 | 
				
			||||||
    filter: drop-shadow(3px 3px 3px #ccc);
 | 
					    filter: drop-shadow(3px 3px 3px #ccc);
 | 
				
			||||||
    border-radius: 7px;
 | 
					    border-radius: 7px;
 | 
				
			||||||
    max-width: 100%;
 | 
					    max-width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
figure video {
 | 
					#site figure video {
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    max-height: 450px;
 | 
					    max-height: 450px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
figcaption {
 | 
					#site figcaption {
 | 
				
			||||||
    text-align: center;
 | 
					    text-align: center;
 | 
				
			||||||
    font-style: italic;
 | 
					    font-style: italic;
 | 
				
			||||||
    padding-top: 10px;
 | 
					    padding-top: 10px;
 | 
				
			||||||
| 
						 | 
					@ -143,18 +137,18 @@ figcaption {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Screenshots */
 | 
					/* Screenshots */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#screenshots {
 | 
					#site #screenshots {
 | 
				
			||||||
    text-align: center;
 | 
					    text-align: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#screenshots img {
 | 
					#site #screenshots img {
 | 
				
			||||||
    height: 190px;
 | 
					    height: 190px;
 | 
				
			||||||
    margin: 3px;
 | 
					    margin: 3px;
 | 
				
			||||||
    border-radius: 5px;
 | 
					    border-radius: 5px;
 | 
				
			||||||
    filter: drop-shadow(2px 2px 2px #ddd);
 | 
					    filter: drop-shadow(2px 2px 2px #ddd);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#screenshots .nowrap {
 | 
					#site #screenshots .nowrap {
 | 
				
			||||||
    white-space: nowrap;
 | 
					    white-space: nowrap;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -220,23 +214,23 @@ figcaption {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Header */
 | 
					/* Header */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header {
 | 
					#site #header {
 | 
				
			||||||
    background: #338574;
 | 
					    background: #338574;
 | 
				
			||||||
    height: 130px;
 | 
					    height: 130px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header #headerBox {
 | 
					#site #header #headerBox {
 | 
				
			||||||
    max-width: 900px;
 | 
					    max-width: 900px;
 | 
				
			||||||
    margin: 0 auto;
 | 
					    margin: 0 auto;
 | 
				
			||||||
    padding: 0 10px;
 | 
					    padding: 0 10px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header #logo {
 | 
					#site #header #logo {
 | 
				
			||||||
    margin-top: 23px;
 | 
					    margin-top: 23px;
 | 
				
			||||||
    float: left;
 | 
					    float: left;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header #name {
 | 
					#site #header #name {
 | 
				
			||||||
    float: left;
 | 
					    float: left;
 | 
				
			||||||
    color: white;
 | 
					    color: white;
 | 
				
			||||||
    font-size: 2.6em;
 | 
					    font-size: 2.6em;
 | 
				
			||||||
| 
						 | 
					@ -244,28 +238,28 @@ figcaption {
 | 
				
			||||||
    margin: 35px 0 0 20px;
 | 
					    margin: 35px 0 0 20px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header ol {
 | 
					#site #header ol {
 | 
				
			||||||
    list-style-type: none;
 | 
					    list-style-type: none;
 | 
				
			||||||
    float: right;
 | 
					    float: right;
 | 
				
			||||||
    margin-top: 80px;
 | 
					    margin-top: 80px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header ol li {
 | 
					#site #header ol li {
 | 
				
			||||||
    display: inline-block;
 | 
					    display: inline-block;
 | 
				
			||||||
    margin: 0 10px;
 | 
					    margin: 0 10px;
 | 
				
			||||||
    font-weight: 400;
 | 
					    font-weight: 400;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header ol li a, nav ol li a:visited {
 | 
					#site #header ol li a, nav ol li a:visited {
 | 
				
			||||||
    color: white;
 | 
					    color: white;
 | 
				
			||||||
    text-decoration: none;
 | 
					    text-decoration: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#header ol li a:hover {
 | 
					#site #header ol li a:hover {
 | 
				
			||||||
    text-decoration: underline;
 | 
					    text-decoration: underline;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
li {
 | 
					#site li {
 | 
				
			||||||
    padding: 4px 0;
 | 
					    padding: 4px 0;
 | 
				
			||||||
    margin: 4px 0;
 | 
					    margin: 4px 0;
 | 
				
			||||||
    font-size: 0.9em;
 | 
					    font-size: 0.9em;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,9 +6,9 @@ import {
 | 
				
			||||||
    topicUrlAuth,
 | 
					    topicUrlAuth,
 | 
				
			||||||
    topicUrlJsonPoll,
 | 
					    topicUrlJsonPoll,
 | 
				
			||||||
    topicUrlJsonPollWithSince,
 | 
					    topicUrlJsonPollWithSince,
 | 
				
			||||||
    userAccountUrl,
 | 
					    accountSettingsUrl,
 | 
				
			||||||
    userTokenUrl,
 | 
					    accountTokenUrl,
 | 
				
			||||||
    userStatsUrl, userSubscriptionUrl, userSubscriptionDeleteUrl
 | 
					    userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl
 | 
				
			||||||
} from "./utils";
 | 
					} from "./utils";
 | 
				
			||||||
import userManager from "./UserManager";
 | 
					import userManager from "./UserManager";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -120,7 +120,7 @@ class Api {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async login(baseUrl, user) {
 | 
					    async login(baseUrl, user) {
 | 
				
			||||||
        const url = userTokenUrl(baseUrl);
 | 
					        const url = accountTokenUrl(baseUrl);
 | 
				
			||||||
        console.log(`[Api] Checking auth for ${url}`);
 | 
					        console.log(`[Api] Checking auth for ${url}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
            headers: maybeWithBasicAuth({}, user)
 | 
					            headers: maybeWithBasicAuth({}, user)
 | 
				
			||||||
| 
						 | 
					@ -136,7 +136,7 @@ class Api {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async logout(baseUrl, token) {
 | 
					    async logout(baseUrl, token) {
 | 
				
			||||||
        const url = userTokenUrl(baseUrl);
 | 
					        const url = accountTokenUrl(baseUrl);
 | 
				
			||||||
        console.log(`[Api] Logging out from ${url} using token ${token}`);
 | 
					        console.log(`[Api] Logging out from ${url} using token ${token}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
            method: "DELETE",
 | 
					            method: "DELETE",
 | 
				
			||||||
| 
						 | 
					@ -159,8 +159,24 @@ class Api {
 | 
				
			||||||
        return stats;
 | 
					        return stats;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async userAccount(baseUrl, token) {
 | 
					    async createAccount(baseUrl, username, password) {
 | 
				
			||||||
        const url = userAccountUrl(baseUrl);
 | 
					        const url = accountUrl(baseUrl);
 | 
				
			||||||
 | 
					        const body = JSON.stringify({
 | 
				
			||||||
 | 
					            username: username,
 | 
				
			||||||
 | 
					            password: password
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        console.log(`[Api] Creating user account ${url}`);
 | 
				
			||||||
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
 | 
					            method: "POST",
 | 
				
			||||||
 | 
					            body: body
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        if (response.status !== 200) {
 | 
				
			||||||
 | 
					            throw new Error(`Unexpected server response ${response.status}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async getAccountSettings(baseUrl, token) {
 | 
				
			||||||
 | 
					        const url = accountSettingsUrl(baseUrl);
 | 
				
			||||||
        console.log(`[Api] Fetching user account ${url}`);
 | 
					        console.log(`[Api] Fetching user account ${url}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
            headers: maybeWithBearerAuth({}, token)
 | 
					            headers: maybeWithBearerAuth({}, token)
 | 
				
			||||||
| 
						 | 
					@ -173,8 +189,8 @@ class Api {
 | 
				
			||||||
        return account;
 | 
					        return account;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async updateUserAccount(baseUrl, token, payload) {
 | 
					    async updateAccountSettings(baseUrl, token, payload) {
 | 
				
			||||||
        const url = userAccountUrl(baseUrl);
 | 
					        const url = accountSettingsUrl(baseUrl);
 | 
				
			||||||
        const body = JSON.stringify(payload);
 | 
					        const body = JSON.stringify(payload);
 | 
				
			||||||
        console.log(`[Api] Updating user account ${url}: ${body}`);
 | 
					        console.log(`[Api] Updating user account ${url}: ${body}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
| 
						 | 
					@ -187,8 +203,8 @@ class Api {
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async userSubscriptionAdd(baseUrl, token, payload) {
 | 
					    async addAccountSubscription(baseUrl, token, payload) {
 | 
				
			||||||
        const url = userSubscriptionUrl(baseUrl);
 | 
					        const url = accountSubscriptionUrl(baseUrl);
 | 
				
			||||||
        const body = JSON.stringify(payload);
 | 
					        const body = JSON.stringify(payload);
 | 
				
			||||||
        console.log(`[Api] Adding user subscription ${url}: ${body}`);
 | 
					        console.log(`[Api] Adding user subscription ${url}: ${body}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
| 
						 | 
					@ -204,8 +220,8 @@ class Api {
 | 
				
			||||||
        return subscription;
 | 
					        return subscription;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async userSubscriptionDelete(baseUrl, token, remoteId) {
 | 
					    async deleteAccountSubscription(baseUrl, token, remoteId) {
 | 
				
			||||||
        const url = userSubscriptionDeleteUrl(baseUrl, remoteId);
 | 
					        const url = accountSubscriptionSingleUrl(baseUrl, remoteId);
 | 
				
			||||||
        console.log(`[Api] Removing user subscription ${url}`);
 | 
					        console.log(`[Api] Removing user subscription ${url}`);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
            method: "DELETE",
 | 
					            method: "DELETE",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,10 +19,11 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
 | 
				
			||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
 | 
					export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
 | 
				
			||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
 | 
					export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
 | 
				
			||||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
 | 
					export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
 | 
				
			||||||
export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`;
 | 
					export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
 | 
				
			||||||
export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`;
 | 
					export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
 | 
				
			||||||
export const userSubscriptionUrl = (baseUrl) => `${baseUrl}/user/subscription`;
 | 
					export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
 | 
				
			||||||
export const userSubscriptionDeleteUrl = (baseUrl, id) => `${baseUrl}/user/subscription/${id}`;
 | 
					export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
 | 
				
			||||||
 | 
					export const accountSubscriptionSingleUrl = (baseUrl, id) => `${baseUrl}/v1/account/subscription/${id}`;
 | 
				
			||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 | 
					export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 | 
				
			||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 | 
					export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 | 
				
			||||||
export const expandSecureUrl = (url) => `https://${url}`;
 | 
					export const expandSecureUrl = (url) => `https://${url}`;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -115,7 +115,7 @@ const SettingsIcons = (props) => {
 | 
				
			||||||
        handleClose(event);
 | 
					        handleClose(event);
 | 
				
			||||||
        await subscriptionManager.remove(props.subscription.id);
 | 
					        await subscriptionManager.remove(props.subscription.id);
 | 
				
			||||||
        if (session.exists() && props.subscription.remoteId) {
 | 
					        if (session.exists() && props.subscription.remoteId) {
 | 
				
			||||||
            await api.userSubscriptionDelete("http://localhost:2586", session.token(), props.subscription.remoteId);
 | 
					            await api.deleteAccountSubscription("http://localhost:2586", session.token(), props.subscription.remoteId);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        const newSelected = await subscriptionManager.first(); // May be undefined
 | 
					        const newSelected = await subscriptionManager.first(); // May be undefined
 | 
				
			||||||
        if (newSelected) {
 | 
					        if (newSelected) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -91,7 +91,7 @@ const Layout = () => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useEffect(() => {
 | 
					    useEffect(() => {
 | 
				
			||||||
        (async () => {
 | 
					        (async () => {
 | 
				
			||||||
            const account = await api.userAccount("http://localhost:2586", session.token());
 | 
					            const account = await api.getAccountSettings("http://localhost:2586", session.token());
 | 
				
			||||||
            if (account) {
 | 
					            if (account) {
 | 
				
			||||||
                if (account.language) {
 | 
					                if (account.language) {
 | 
				
			||||||
                    await i18n.changeLanguage(account.language);
 | 
					                    await i18n.changeLanguage(account.language);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,8 @@ import Box from "@mui/material/Box";
 | 
				
			||||||
import api from "../app/Api";
 | 
					import api from "../app/Api";
 | 
				
			||||||
import routes from "./routes";
 | 
					import routes from "./routes";
 | 
				
			||||||
import session from "../app/Session";
 | 
					import session from "../app/Session";
 | 
				
			||||||
 | 
					import logo from "../img/ntfy2.svg";
 | 
				
			||||||
 | 
					import {NavLink} from "react-router-dom";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Login = () => {
 | 
					const Login = () => {
 | 
				
			||||||
    const handleSubmit = async (event) => {
 | 
					    const handleSubmit = async (event) => {
 | 
				
			||||||
| 
						 | 
					@ -24,68 +26,59 @@ const Login = () => {
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <>
 | 
					        <Box
 | 
				
			||||||
            <Box
 | 
					            sx={{
 | 
				
			||||||
                sx={{
 | 
					                display: 'flex',
 | 
				
			||||||
                    marginTop: 8,
 | 
					                flexGrow: 1,
 | 
				
			||||||
                    display: 'flex',
 | 
					                justifyContent: 'center',
 | 
				
			||||||
                    flexDirection: 'column',
 | 
					                flexDirection: 'column',
 | 
				
			||||||
                    alignItems: 'center',
 | 
					                alignContent: 'center',
 | 
				
			||||||
                }}
 | 
					                alignItems: 'center',
 | 
				
			||||||
            >
 | 
					                height: '100vh'
 | 
				
			||||||
                <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
 | 
					            }}
 | 
				
			||||||
                    <LockOutlinedIcon/>
 | 
					        >
 | 
				
			||||||
                </Avatar>
 | 
					            <Avatar
 | 
				
			||||||
                <Typography component="h1" variant="h5">
 | 
					                sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
 | 
				
			||||||
 | 
					                src={logo}
 | 
				
			||||||
 | 
					                variant="rounded"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <Typography sx={{ typography: 'h6' }}>
 | 
				
			||||||
 | 
					                Sign in to your ntfy account
 | 
				
			||||||
 | 
					            </Typography>
 | 
				
			||||||
 | 
					            <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
 | 
				
			||||||
 | 
					                <TextField
 | 
				
			||||||
 | 
					                    margin="dense"
 | 
				
			||||||
 | 
					                    required
 | 
				
			||||||
 | 
					                    fullWidth
 | 
				
			||||||
 | 
					                    id="username"
 | 
				
			||||||
 | 
					                    label="Username"
 | 
				
			||||||
 | 
					                    name="username"
 | 
				
			||||||
 | 
					                    autoFocus
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					                <TextField
 | 
				
			||||||
 | 
					                    margin="dense"
 | 
				
			||||||
 | 
					                    required
 | 
				
			||||||
 | 
					                    fullWidth
 | 
				
			||||||
 | 
					                    name="password"
 | 
				
			||||||
 | 
					                    label="Password"
 | 
				
			||||||
 | 
					                    type="password"
 | 
				
			||||||
 | 
					                    id="password"
 | 
				
			||||||
 | 
					                    autoComplete="current-password"
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					                <Button
 | 
				
			||||||
 | 
					                    type="submit"
 | 
				
			||||||
 | 
					                    fullWidth
 | 
				
			||||||
 | 
					                    variant="contained"
 | 
				
			||||||
 | 
					                    sx={{mt: 2, mb: 2}}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
                    Sign in
 | 
					                    Sign in
 | 
				
			||||||
                </Typography>
 | 
					                </Button>
 | 
				
			||||||
                <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
 | 
					                <Box sx={{width: "100%"}}>
 | 
				
			||||||
                    <TextField
 | 
					                    <NavLink to="#" variant="body1" sx={{float: "left"}}>Reset password</NavLink>
 | 
				
			||||||
                        margin="normal"
 | 
					                    <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign Up</NavLink></div>
 | 
				
			||||||
                        required
 | 
					 | 
				
			||||||
                        fullWidth
 | 
					 | 
				
			||||||
                        id="username"
 | 
					 | 
				
			||||||
                        label="Username"
 | 
					 | 
				
			||||||
                        name="username"
 | 
					 | 
				
			||||||
                        autoFocus
 | 
					 | 
				
			||||||
                    />
 | 
					 | 
				
			||||||
                    <TextField
 | 
					 | 
				
			||||||
                        margin="normal"
 | 
					 | 
				
			||||||
                        required
 | 
					 | 
				
			||||||
                        fullWidth
 | 
					 | 
				
			||||||
                        name="password"
 | 
					 | 
				
			||||||
                        label="Password"
 | 
					 | 
				
			||||||
                        type="password"
 | 
					 | 
				
			||||||
                        id="password"
 | 
					 | 
				
			||||||
                        autoComplete="current-password"
 | 
					 | 
				
			||||||
                    />
 | 
					 | 
				
			||||||
                    <FormControlLabel
 | 
					 | 
				
			||||||
                        control={<Checkbox value="remember" color="primary"/>}
 | 
					 | 
				
			||||||
                        label="Remember me"
 | 
					 | 
				
			||||||
                    />
 | 
					 | 
				
			||||||
                    <Button
 | 
					 | 
				
			||||||
                        type="submit"
 | 
					 | 
				
			||||||
                        fullWidth
 | 
					 | 
				
			||||||
                        variant="contained"
 | 
					 | 
				
			||||||
                        sx={{mt: 3, mb: 2}}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                        Sign In
 | 
					 | 
				
			||||||
                    </Button>
 | 
					 | 
				
			||||||
                    <Grid container>
 | 
					 | 
				
			||||||
                        <Grid item xs>
 | 
					 | 
				
			||||||
                            <Link href="#" variant="body2">
 | 
					 | 
				
			||||||
                                Forgot password?
 | 
					 | 
				
			||||||
                            </Link>
 | 
					 | 
				
			||||||
                        </Grid>
 | 
					 | 
				
			||||||
                        <Grid item>
 | 
					 | 
				
			||||||
                            <Link to={routes.signup} variant="body2">
 | 
					 | 
				
			||||||
                                {"Don't have an account? Sign Up"}
 | 
					 | 
				
			||||||
                            </Link>
 | 
					 | 
				
			||||||
                        </Grid>
 | 
					 | 
				
			||||||
                    </Grid>
 | 
					 | 
				
			||||||
                </Box>
 | 
					                </Box>
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
        </>
 | 
					        </Box>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -73,7 +73,7 @@ const Sound = () => {
 | 
				
			||||||
    const handleChange = async (ev) => {
 | 
					    const handleChange = async (ev) => {
 | 
				
			||||||
        await prefs.setSound(ev.target.value);
 | 
					        await prefs.setSound(ev.target.value);
 | 
				
			||||||
        if (session.exists()) {
 | 
					        if (session.exists()) {
 | 
				
			||||||
            await api.updateUserAccount("http://localhost:2586", session.token(), {
 | 
					            await api.updateAccountSettings("http://localhost:2586", session.token(), {
 | 
				
			||||||
                notification: {
 | 
					                notification: {
 | 
				
			||||||
                    sound: ev.target.value
 | 
					                    sound: ev.target.value
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
| 
						 | 
					@ -113,7 +113,7 @@ const MinPriority = () => {
 | 
				
			||||||
    const handleChange = async (ev) => {
 | 
					    const handleChange = async (ev) => {
 | 
				
			||||||
        await prefs.setMinPriority(ev.target.value);
 | 
					        await prefs.setMinPriority(ev.target.value);
 | 
				
			||||||
        if (session.exists()) {
 | 
					        if (session.exists()) {
 | 
				
			||||||
            await api.updateUserAccount("http://localhost:2586", session.token(), {
 | 
					            await api.updateAccountSettings("http://localhost:2586", session.token(), {
 | 
				
			||||||
                notification: {
 | 
					                notification: {
 | 
				
			||||||
                    min_priority: ev.target.value
 | 
					                    min_priority: ev.target.value
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
| 
						 | 
					@ -163,7 +163,7 @@ const DeleteAfter = () => {
 | 
				
			||||||
    const handleChange = async (ev) => {
 | 
					    const handleChange = async (ev) => {
 | 
				
			||||||
        await prefs.setDeleteAfter(ev.target.value);
 | 
					        await prefs.setDeleteAfter(ev.target.value);
 | 
				
			||||||
        if (session.exists()) {
 | 
					        if (session.exists()) {
 | 
				
			||||||
            await api.updateUserAccount("http://localhost:2586", session.token(), {
 | 
					            await api.updateAccountSettings("http://localhost:2586", session.token(), {
 | 
				
			||||||
                notification: {
 | 
					                notification: {
 | 
				
			||||||
                    delete_after: ev.target.value
 | 
					                    delete_after: ev.target.value
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
| 
						 | 
					@ -467,7 +467,7 @@ const Language = () => {
 | 
				
			||||||
    const handleChange = async (ev) => {
 | 
					    const handleChange = async (ev) => {
 | 
				
			||||||
        await i18n.changeLanguage(ev.target.value);
 | 
					        await i18n.changeLanguage(ev.target.value);
 | 
				
			||||||
        if (session.exists()) {
 | 
					        if (session.exists()) {
 | 
				
			||||||
            await api.updateUserAccount("http://localhost:2586", session.token(), {
 | 
					            await api.updateAccountSettings("http://localhost:2586", session.token(), {
 | 
				
			||||||
                language: ev.target.value
 | 
					                language: ev.target.value
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,24 +1,27 @@
 | 
				
			||||||
import * as React from 'react';
 | 
					import * as React from 'react';
 | 
				
			||||||
import {Avatar, Checkbox, FormControlLabel, Grid, Link, Stack} from "@mui/material";
 | 
					import {Avatar, Link} from "@mui/material";
 | 
				
			||||||
import Typography from "@mui/material/Typography";
 | 
					 | 
				
			||||||
import Container from "@mui/material/Container";
 | 
					 | 
				
			||||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
 | 
					 | 
				
			||||||
import TextField from "@mui/material/TextField";
 | 
					import TextField from "@mui/material/TextField";
 | 
				
			||||||
import Button from "@mui/material/Button";
 | 
					import Button from "@mui/material/Button";
 | 
				
			||||||
import Box from "@mui/material/Box";
 | 
					import Box from "@mui/material/Box";
 | 
				
			||||||
import api from "../app/Api";
 | 
					import api from "../app/Api";
 | 
				
			||||||
import {useNavigate} from "react-router-dom";
 | 
					 | 
				
			||||||
import routes from "./routes";
 | 
					import routes from "./routes";
 | 
				
			||||||
import session from "../app/Session";
 | 
					import session from "../app/Session";
 | 
				
			||||||
 | 
					import logo from "../img/ntfy2.svg";
 | 
				
			||||||
 | 
					import Typography from "@mui/material/Typography";
 | 
				
			||||||
 | 
					import {NavLink} from "react-router-dom";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Signup = () => {
 | 
					const Signup = () => {
 | 
				
			||||||
    const handleSubmit = async (event) => {
 | 
					    const handleSubmit = async (event) => {
 | 
				
			||||||
        event.preventDefault();
 | 
					        event.preventDefault();
 | 
				
			||||||
        const data = new FormData(event.currentTarget);
 | 
					        const data = new FormData(event.currentTarget);
 | 
				
			||||||
 | 
					        const username = data.get('username');
 | 
				
			||||||
 | 
					        const password = data.get('password');
 | 
				
			||||||
        const user = {
 | 
					        const user = {
 | 
				
			||||||
            username: data.get('username'),
 | 
					            username: username,
 | 
				
			||||||
            password: data.get('password'),
 | 
					            password: password
 | 
				
			||||||
        }
 | 
					        }; // FIXME omg so awful
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await api.createAccount("http://localhost:2586"/*window.location.origin*/, username, password);
 | 
				
			||||||
        const token = await api.login("http://localhost:2586"/*window.location.origin*/, user);
 | 
					        const token = await api.login("http://localhost:2586"/*window.location.origin*/, user);
 | 
				
			||||||
        console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
 | 
					        console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
 | 
				
			||||||
        session.store(user.username, token);
 | 
					        session.store(user.username, token);
 | 
				
			||||||
| 
						 | 
					@ -26,68 +29,69 @@ const Signup = () => {
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <>
 | 
					        <Box
 | 
				
			||||||
            <Box
 | 
					            sx={{
 | 
				
			||||||
                sx={{
 | 
					                display: 'flex',
 | 
				
			||||||
                    marginTop: 8,
 | 
					                flexGrow: 1,
 | 
				
			||||||
                    display: 'flex',
 | 
					                justifyContent: 'center',
 | 
				
			||||||
                    flexDirection: 'column',
 | 
					                flexDirection: 'column',
 | 
				
			||||||
                    alignItems: 'center',
 | 
					                alignContent: 'center',
 | 
				
			||||||
                }}
 | 
					                alignItems: 'center',
 | 
				
			||||||
            >
 | 
					                height: '100vh'
 | 
				
			||||||
                <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
 | 
					            }}
 | 
				
			||||||
                    <LockOutlinedIcon/>
 | 
					        >
 | 
				
			||||||
                </Avatar>
 | 
					            <Avatar
 | 
				
			||||||
                <Typography component="h1" variant="h5">
 | 
					                sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
 | 
				
			||||||
                    Sign in
 | 
					                src={logo}
 | 
				
			||||||
                </Typography>
 | 
					                variant="rounded"
 | 
				
			||||||
                <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
 | 
					            />
 | 
				
			||||||
                    <TextField
 | 
					            <Typography sx={{ typography: 'h6' }}>
 | 
				
			||||||
                        margin="normal"
 | 
					                Create a ntfy account
 | 
				
			||||||
                        required
 | 
					            </Typography>
 | 
				
			||||||
                        fullWidth
 | 
					            <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
 | 
				
			||||||
                        id="username"
 | 
					                <TextField
 | 
				
			||||||
                        label="Username"
 | 
					                    margin="dense"
 | 
				
			||||||
                        name="username"
 | 
					                    required
 | 
				
			||||||
                        autoFocus
 | 
					                    fullWidth
 | 
				
			||||||
                    />
 | 
					                    id="username"
 | 
				
			||||||
                    <TextField
 | 
					                    label="Username"
 | 
				
			||||||
                        margin="normal"
 | 
					                    name="username"
 | 
				
			||||||
                        required
 | 
					                    autoFocus
 | 
				
			||||||
                        fullWidth
 | 
					                />
 | 
				
			||||||
                        name="password"
 | 
					                <TextField
 | 
				
			||||||
                        label="Password"
 | 
					                    margin="dense"
 | 
				
			||||||
                        type="password"
 | 
					                    required
 | 
				
			||||||
                        id="password"
 | 
					                    fullWidth
 | 
				
			||||||
                        autoComplete="current-password"
 | 
					                    name="password"
 | 
				
			||||||
                    />
 | 
					                    label="Password"
 | 
				
			||||||
                    <FormControlLabel
 | 
					                    type="password"
 | 
				
			||||||
                        control={<Checkbox value="remember" color="primary"/>}
 | 
					                    id="password"
 | 
				
			||||||
                        label="Remember me"
 | 
					                    autoComplete="current-password"
 | 
				
			||||||
                    />
 | 
					                />
 | 
				
			||||||
                    <Button
 | 
					                <TextField
 | 
				
			||||||
                        type="submit"
 | 
					                    margin="dense"
 | 
				
			||||||
                        fullWidth
 | 
					                    required
 | 
				
			||||||
                        variant="contained"
 | 
					                    fullWidth
 | 
				
			||||||
                        sx={{mt: 3, mb: 2}}
 | 
					                    name="confirm-password"
 | 
				
			||||||
                    >
 | 
					                    label="Confirm password"
 | 
				
			||||||
                        Sign up
 | 
					                    type="password"
 | 
				
			||||||
                    </Button>
 | 
					                    id="confirm-password"
 | 
				
			||||||
                    <Grid container>
 | 
					                />
 | 
				
			||||||
                        <Grid item xs>
 | 
					                <Button
 | 
				
			||||||
                            <Link href="#" variant="body2">
 | 
					                    type="submit"
 | 
				
			||||||
                                Forgot password?
 | 
					                    fullWidth
 | 
				
			||||||
                            </Link>
 | 
					                    variant="contained"
 | 
				
			||||||
                        </Grid>
 | 
					                    sx={{mt: 2, mb: 2}}
 | 
				
			||||||
                        <Grid item>
 | 
					                >
 | 
				
			||||||
                            <Link to={routes.signup} variant="body2">
 | 
					                    Sign up
 | 
				
			||||||
                                {"Don't have an account? Sign Up"}
 | 
					                </Button>
 | 
				
			||||||
                            </Link>
 | 
					 | 
				
			||||||
                        </Grid>
 | 
					 | 
				
			||||||
                    </Grid>
 | 
					 | 
				
			||||||
                </Box>
 | 
					 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
        </>
 | 
					            <Typography sx={{mb: 4}}>
 | 
				
			||||||
 | 
					                <NavLink to={routes.login} variant="body1">
 | 
				
			||||||
 | 
					                    Already have an account? Sign in
 | 
				
			||||||
 | 
					                </NavLink>
 | 
				
			||||||
 | 
					            </Typography>
 | 
				
			||||||
 | 
					        </Box>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,7 @@ const SiteLayout = (props) => {
 | 
				
			||||||
                        <li><NavLink to={routes.home} activeStyle>Features</NavLink></li>
 | 
					                        <li><NavLink to={routes.home} activeStyle>Features</NavLink></li>
 | 
				
			||||||
                        <li><NavLink to={routes.pricing} activeStyle>Pricing</NavLink></li>
 | 
					                        <li><NavLink to={routes.pricing} activeStyle>Pricing</NavLink></li>
 | 
				
			||||||
                        <li><NavLink to="/docs" reloadDocument={true} activeStyle>Docs</NavLink></li>
 | 
					                        <li><NavLink to="/docs" reloadDocument={true} activeStyle>Docs</NavLink></li>
 | 
				
			||||||
                        {session.exists() && <li><NavLink to={routes.signup} activeStyle>Sign up</NavLink></li>}
 | 
					                        {!session.exists() && <li><NavLink to={routes.signup} activeStyle>Sign up</NavLink></li>}
 | 
				
			||||||
                        {!session.exists() && <li><NavLink to={routes.login} activeStyle>Login</NavLink></li>}
 | 
					                        {!session.exists() && <li><NavLink to={routes.login} activeStyle>Login</NavLink></li>}
 | 
				
			||||||
                        <li><NavLink to={routes.app} activeStyle>Open app</NavLink></li>
 | 
					                        <li><NavLink to={routes.app} activeStyle>Open app</NavLink></li>
 | 
				
			||||||
                    </ol>
 | 
					                    </ol>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,7 +28,7 @@ const SubscribeDialog = (props) => {
 | 
				
			||||||
        const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
 | 
					        const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
 | 
				
			||||||
        const subscription = await subscriptionManager.add(actualBaseUrl, topic);
 | 
					        const subscription = await subscriptionManager.add(actualBaseUrl, topic);
 | 
				
			||||||
        if (session.exists()) {
 | 
					        if (session.exists()) {
 | 
				
			||||||
            const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), {
 | 
					            const remoteSubscription = await api.addAccountSubscription("http://localhost:2586", session.token(), {
 | 
				
			||||||
                base_url: actualBaseUrl,
 | 
					                base_url: actualBaseUrl,
 | 
				
			||||||
                topic: topic
 | 
					                topic: topic
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -64,7 +64,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
 | 
				
			||||||
            (async () => {
 | 
					            (async () => {
 | 
				
			||||||
                const subscription = await subscriptionManager.add(baseUrl, params.topic);
 | 
					                const subscription = await subscriptionManager.add(baseUrl, params.topic);
 | 
				
			||||||
                if (session.exists()) {
 | 
					                if (session.exists()) {
 | 
				
			||||||
                    const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), {
 | 
					                    const remoteSubscription = await api.addAccountSubscription("http://localhost:2586", session.token(), {
 | 
				
			||||||
                        base_url: baseUrl,
 | 
					                        base_url: baseUrl,
 | 
				
			||||||
                        topic: params.topic
 | 
					                        topic: params.topic
 | 
				
			||||||
                    });
 | 
					                    });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,255 +1 @@
 | 
				
			||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
					<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50mm" height="50mm" viewBox="0 0 50 50"><defs><linearGradient id="b"><stop offset="0" style="stop-color:#348878;stop-opacity:1"/><stop offset="1" style="stop-color:#52bca6;stop-opacity:1"/></linearGradient><linearGradient id="a"><stop offset="0" style="stop-color:#348878;stop-opacity:1"/><stop offset="1" style="stop-color:#56bda8;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="160.722" x2="168.412" y1="128.533" y2="134.326" gradientTransform="matrix(3.74959 0 0 3.74959 -541.79 -387.599)" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1=".034" x2="50.319" y1="0" y2="50.285" gradientTransform="matrix(.99434 0 0 .99434 -.034 0)" gradientUnits="userSpaceOnUse"/><filter id="d" width="1.176" height="1.211" x="-.076" y="-.092" style="color-interpolation-filters:sRGB"><feFlood flood-color="#000" flood-opacity=".192" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur" stdDeviation="4"/><feOffset dx="3" dy="2.954" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="composite2"/></filter></defs><g style="display:inline"><path d="M0 0h50v50H0z" style="fill:url(#c);fill-opacity:1;stroke:none;stroke-width:.286502;stroke-linejoin:bevel"/></g><g style="display:inline"><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#000;display:inline;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none;filter:url(#d)" transform="scale(.26458)"/></g><g style="display:inline"><path d="M88.2 95.309H64.92c-1.601 0-2.91 1.236-2.91 2.746l.022 18.602-.435 2.506 6.231-1.881H88.2c1.6 0 2.91-1.236 2.91-2.747v-16.48c0-1.51-1.31-2.746-2.91-2.746z" style="color:#000;fill:url(#e);stroke:none;stroke-width:2.49558;-inkscape-stroke:none" transform="translate(-51.147 -81.516)"/><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#000;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none" transform="scale(.26458)"/><g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#000;stroke:none;stroke-width:.525121"><path d="M62.57 116.77v-1.312l3.28-1.459q.159-.068.306-.102.158-.045.283-.068l.271-.022v-.09q-.136-.012-.271-.046-.125-.023-.283-.057-.147-.045-.306-.113l-3.28-1.459v-1.323l5.068 2.319v1.413z" style="color:#000;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/><path d="M62.309 110.31v1.903l3.437 1.53.022.007-.022.008-3.437 1.53v1.892l.37-.17 5.221-2.39v-1.75zm.525.817 4.541 2.08v1.076l-4.541 2.078v-.732l3.12-1.389.003-.002a1.56 1.56 0 0 1 .258-.086h.006l.008-.002c.094-.027.176-.047.246-.06l.498-.041v-.574l-.24-.02a1.411 1.411 0 0 1-.231-.04l-.008-.001-.008-.002a9.077 9.077 0 0 1-.263-.053 2.781 2.781 0 0 1-.266-.097l-.004-.002-3.119-1.39z" style="color:#000;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/></g><g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#000;stroke:none;stroke-width:.525121"><path d="M69.171 117.754h5.43v1.278h-5.43Z" style="color:#000;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/><path d="M68.908 117.492v1.802h5.955v-1.802zm.526.524h4.904v.754h-4.904z" style="color:#000;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/></g></g></svg>
 | 
				
			||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<svg
 | 
					 | 
				
			||||||
   width="50mm"
 | 
					 | 
				
			||||||
   height="50mm"
 | 
					 | 
				
			||||||
   viewBox="0 0 50 50"
 | 
					 | 
				
			||||||
   version="1.1"
 | 
					 | 
				
			||||||
   id="svg8"
 | 
					 | 
				
			||||||
   inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
 | 
					 | 
				
			||||||
   sodipodi:docname="appstore_ios.svg"
 | 
					 | 
				
			||||||
   inkscape:export-filename="/home/pheckel/Code/ntfy-android/assets/appstore_ios.png"
 | 
					 | 
				
			||||||
   inkscape:export-xdpi="520.19202"
 | 
					 | 
				
			||||||
   inkscape:export-ydpi="520.19202"
 | 
					 | 
				
			||||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
					 | 
				
			||||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
					 | 
				
			||||||
   xmlns:xlink="http://www.w3.org/1999/xlink"
 | 
					 | 
				
			||||||
   xmlns="http://www.w3.org/2000/svg"
 | 
					 | 
				
			||||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
					 | 
				
			||||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
					 | 
				
			||||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
					 | 
				
			||||||
   xmlns:dc="http://purl.org/dc/elements/1.1/">
 | 
					 | 
				
			||||||
  <defs
 | 
					 | 
				
			||||||
     id="defs2">
 | 
					 | 
				
			||||||
    <linearGradient
 | 
					 | 
				
			||||||
       inkscape:collect="always"
 | 
					 | 
				
			||||||
       id="linearGradient4714">
 | 
					 | 
				
			||||||
      <stop
 | 
					 | 
				
			||||||
         style="stop-color:#348878;stop-opacity:1"
 | 
					 | 
				
			||||||
         offset="0"
 | 
					 | 
				
			||||||
         id="stop4710" />
 | 
					 | 
				
			||||||
      <stop
 | 
					 | 
				
			||||||
         style="stop-color:#52bca6;stop-opacity:1"
 | 
					 | 
				
			||||||
         offset="1"
 | 
					 | 
				
			||||||
         id="stop4712" />
 | 
					 | 
				
			||||||
    </linearGradient>
 | 
					 | 
				
			||||||
    <linearGradient
 | 
					 | 
				
			||||||
       inkscape:collect="always"
 | 
					 | 
				
			||||||
       id="linearGradient28858-5">
 | 
					 | 
				
			||||||
      <stop
 | 
					 | 
				
			||||||
         style="stop-color:#348878;stop-opacity:1"
 | 
					 | 
				
			||||||
         offset="0"
 | 
					 | 
				
			||||||
         id="stop28854-3" />
 | 
					 | 
				
			||||||
      <stop
 | 
					 | 
				
			||||||
         style="stop-color:#56bda8;stop-opacity:1"
 | 
					 | 
				
			||||||
         offset="1"
 | 
					 | 
				
			||||||
         id="stop28856-5" />
 | 
					 | 
				
			||||||
    </linearGradient>
 | 
					 | 
				
			||||||
    <linearGradient
 | 
					 | 
				
			||||||
       inkscape:collect="always"
 | 
					 | 
				
			||||||
       xlink:href="#linearGradient28858-5"
 | 
					 | 
				
			||||||
       id="linearGradient3255"
 | 
					 | 
				
			||||||
       x1="160.72209"
 | 
					 | 
				
			||||||
       y1="128.53317"
 | 
					 | 
				
			||||||
       x2="168.41153"
 | 
					 | 
				
			||||||
       y2="134.32626"
 | 
					 | 
				
			||||||
       gradientUnits="userSpaceOnUse"
 | 
					 | 
				
			||||||
       gradientTransform="matrix(3.7495873,0,0,3.7495873,-541.79055,-387.59852)" />
 | 
					 | 
				
			||||||
    <linearGradient
 | 
					 | 
				
			||||||
       inkscape:collect="always"
 | 
					 | 
				
			||||||
       xlink:href="#linearGradient4714"
 | 
					 | 
				
			||||||
       id="linearGradient4633"
 | 
					 | 
				
			||||||
       x1="0.034492966"
 | 
					 | 
				
			||||||
       y1="-0.0003150744"
 | 
					 | 
				
			||||||
       x2="50.319355"
 | 
					 | 
				
			||||||
       y2="50.284546"
 | 
					 | 
				
			||||||
       gradientUnits="userSpaceOnUse"
 | 
					 | 
				
			||||||
       gradientTransform="matrix(0.99433502,0,0,0.99433502,-0.03429756,-1.7848888e-6)" />
 | 
					 | 
				
			||||||
    <filter
 | 
					 | 
				
			||||||
       style="color-interpolation-filters:sRGB;"
 | 
					 | 
				
			||||||
       inkscape:label="Drop Shadow"
 | 
					 | 
				
			||||||
       id="filter3958"
 | 
					 | 
				
			||||||
       x="-0.076083149"
 | 
					 | 
				
			||||||
       y="-0.091641662"
 | 
					 | 
				
			||||||
       width="1.1759423"
 | 
					 | 
				
			||||||
       height="1.2114791">
 | 
					 | 
				
			||||||
      <feFlood
 | 
					 | 
				
			||||||
         flood-opacity="0.192157"
 | 
					 | 
				
			||||||
         flood-color="rgb(0,0,0)"
 | 
					 | 
				
			||||||
         result="flood"
 | 
					 | 
				
			||||||
         id="feFlood3948" />
 | 
					 | 
				
			||||||
      <feComposite
 | 
					 | 
				
			||||||
         in="flood"
 | 
					 | 
				
			||||||
         in2="SourceGraphic"
 | 
					 | 
				
			||||||
         operator="in"
 | 
					 | 
				
			||||||
         result="composite1"
 | 
					 | 
				
			||||||
         id="feComposite3950" />
 | 
					 | 
				
			||||||
      <feGaussianBlur
 | 
					 | 
				
			||||||
         in="composite1"
 | 
					 | 
				
			||||||
         stdDeviation="4"
 | 
					 | 
				
			||||||
         result="blur"
 | 
					 | 
				
			||||||
         id="feGaussianBlur3952" />
 | 
					 | 
				
			||||||
      <feOffset
 | 
					 | 
				
			||||||
         dx="3"
 | 
					 | 
				
			||||||
         dy="2.95367"
 | 
					 | 
				
			||||||
         result="offset"
 | 
					 | 
				
			||||||
         id="feOffset3954" />
 | 
					 | 
				
			||||||
      <feComposite
 | 
					 | 
				
			||||||
         in="SourceGraphic"
 | 
					 | 
				
			||||||
         in2="offset"
 | 
					 | 
				
			||||||
         operator="over"
 | 
					 | 
				
			||||||
         result="composite2"
 | 
					 | 
				
			||||||
         id="feComposite3956" />
 | 
					 | 
				
			||||||
    </filter>
 | 
					 | 
				
			||||||
  </defs>
 | 
					 | 
				
			||||||
  <sodipodi:namedview
 | 
					 | 
				
			||||||
     id="base"
 | 
					 | 
				
			||||||
     pagecolor="#ffffff"
 | 
					 | 
				
			||||||
     bordercolor="#666666"
 | 
					 | 
				
			||||||
     borderopacity="1.0"
 | 
					 | 
				
			||||||
     inkscape:pageopacity="0.0"
 | 
					 | 
				
			||||||
     inkscape:pageshadow="2"
 | 
					 | 
				
			||||||
     inkscape:zoom="1.8244841"
 | 
					 | 
				
			||||||
     inkscape:cx="4.6588512"
 | 
					 | 
				
			||||||
     inkscape:cy="174.84395"
 | 
					 | 
				
			||||||
     inkscape:document-units="mm"
 | 
					 | 
				
			||||||
     inkscape:current-layer="layer3"
 | 
					 | 
				
			||||||
     showgrid="false"
 | 
					 | 
				
			||||||
     inkscape:measure-start="0,0"
 | 
					 | 
				
			||||||
     inkscape:measure-end="0,0"
 | 
					 | 
				
			||||||
     inkscape:snap-text-baseline="true"
 | 
					 | 
				
			||||||
     inkscape:window-width="1863"
 | 
					 | 
				
			||||||
     inkscape:window-height="1025"
 | 
					 | 
				
			||||||
     inkscape:window-x="57"
 | 
					 | 
				
			||||||
     inkscape:window-y="27"
 | 
					 | 
				
			||||||
     inkscape:window-maximized="1"
 | 
					 | 
				
			||||||
     fit-margin-top="0"
 | 
					 | 
				
			||||||
     fit-margin-left="0"
 | 
					 | 
				
			||||||
     fit-margin-right="0"
 | 
					 | 
				
			||||||
     fit-margin-bottom="0"
 | 
					 | 
				
			||||||
     showguides="false"
 | 
					 | 
				
			||||||
     inkscape:guide-bbox="true"
 | 
					 | 
				
			||||||
     inkscape:pagecheckerboard="0">
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="10.173514,67.718331"
 | 
					 | 
				
			||||||
       orientation="1,0"
 | 
					 | 
				
			||||||
       id="guide1770" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="39.965574,62.077508"
 | 
					 | 
				
			||||||
       orientation="1,0"
 | 
					 | 
				
			||||||
       id="guide1772" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="10.173514,39.789015"
 | 
					 | 
				
			||||||
       orientation="0,-1"
 | 
					 | 
				
			||||||
       id="guide1774" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="-2.3077334,9.9462015"
 | 
					 | 
				
			||||||
       orientation="0,-1"
 | 
					 | 
				
			||||||
       id="guide1776" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="14.990626,36.198285"
 | 
					 | 
				
			||||||
       orientation="1,0"
 | 
					 | 
				
			||||||
       id="guide4020" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="34.930725,39.789015"
 | 
					 | 
				
			||||||
       orientation="1,0"
 | 
					 | 
				
			||||||
       id="guide4022" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="12.7026,32.00465"
 | 
					 | 
				
			||||||
       orientation="0,-1"
 | 
					 | 
				
			||||||
       id="guide4024" />
 | 
					 | 
				
			||||||
    <sodipodi:guide
 | 
					 | 
				
			||||||
       position="11.377711,17.981227"
 | 
					 | 
				
			||||||
       orientation="0,-1"
 | 
					 | 
				
			||||||
       id="guide4026" />
 | 
					 | 
				
			||||||
  </sodipodi:namedview>
 | 
					 | 
				
			||||||
  <metadata
 | 
					 | 
				
			||||||
     id="metadata5">
 | 
					 | 
				
			||||||
    <rdf:RDF>
 | 
					 | 
				
			||||||
      <cc:Work
 | 
					 | 
				
			||||||
         rdf:about="">
 | 
					 | 
				
			||||||
        <dc:format>image/svg+xml</dc:format>
 | 
					 | 
				
			||||||
        <dc:type
 | 
					 | 
				
			||||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
					 | 
				
			||||||
      </cc:Work>
 | 
					 | 
				
			||||||
    </rdf:RDF>
 | 
					 | 
				
			||||||
  </metadata>
 | 
					 | 
				
			||||||
  <g
 | 
					 | 
				
			||||||
     inkscape:groupmode="layer"
 | 
					 | 
				
			||||||
     id="layer2"
 | 
					 | 
				
			||||||
     inkscape:label="background"
 | 
					 | 
				
			||||||
     style="display:inline">
 | 
					 | 
				
			||||||
    <rect
 | 
					 | 
				
			||||||
       style="fill:url(#linearGradient4633);fill-opacity:1;stroke:none;stroke-width:0.286502;stroke-linejoin:bevel"
 | 
					 | 
				
			||||||
       id="rect4545"
 | 
					 | 
				
			||||||
       width="50"
 | 
					 | 
				
			||||||
       height="50"
 | 
					 | 
				
			||||||
       x="0"
 | 
					 | 
				
			||||||
       y="-0.0003150744" />
 | 
					 | 
				
			||||||
  </g>
 | 
					 | 
				
			||||||
  <g
 | 
					 | 
				
			||||||
     inkscape:groupmode="layer"
 | 
					 | 
				
			||||||
     id="layer5"
 | 
					 | 
				
			||||||
     inkscape:label="drop shadow"
 | 
					 | 
				
			||||||
     style="display:inline">
 | 
					 | 
				
			||||||
    <path
 | 
					 | 
				
			||||||
       id="path3646"
 | 
					 | 
				
			||||||
       style="color:#000000;display:inline;fill:#ffffff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none;filter:url(#filter3958)"
 | 
					 | 
				
			||||||
       d="m 50.400391,46.882812 c -9.16879,0 -17.023438,7.2146 -17.023438,16.386719 v 0.0078 l 0.08984,71.369139 -2.302735,16.99219 31.3125,-8.31836 h 77.841802 c 9.16877,0 17.02344,-7.22425 17.02344,-16.39648 V 63.269531 c 0,-9.169496 -7.85031,-16.382463 -17.01563,-16.386719 h -0.008 z m 0,11.566407 h 89.917969 0.008 c 3.22151,0.0033 5.44922,2.346918 5.44922,4.820312 v 63.654299 c 0,2.47551 -2.23164,4.82031 -5.45703,4.82031 H 60.779297 l -15.908203,4.80664 0.162109,-0.9375 -0.08789,-72.343749 c 0,-2.475337 2.229739,-4.820312 5.455078,-4.820312 z"
 | 
					 | 
				
			||||||
       transform="scale(0.26458333)" />
 | 
					 | 
				
			||||||
  </g>
 | 
					 | 
				
			||||||
  <g
 | 
					 | 
				
			||||||
     inkscape:label="foreground"
 | 
					 | 
				
			||||||
     inkscape:groupmode="layer"
 | 
					 | 
				
			||||||
     id="layer1"
 | 
					 | 
				
			||||||
     transform="translate(-51.147327,-81.515579)"
 | 
					 | 
				
			||||||
     style="display:inline">
 | 
					 | 
				
			||||||
    <path
 | 
					 | 
				
			||||||
       style="color:#000000;fill:url(#linearGradient3255);stroke:none;stroke-width:2.49558;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
       d="M 88.200706,95.308804 H 64.918622 c -1.600657,0 -2.910245,1.235977 -2.910245,2.74661 l 0.02224,18.601596 -0.434711,2.5057 6.231592,-1.88118 h 20.371766 c 1.600658,0 2.910282,-1.23597 2.910282,-2.74664 V 98.055414 c 0,-1.510633 -1.309624,-2.74661 -2.910282,-2.74661 z"
 | 
					 | 
				
			||||||
       id="path7368" />
 | 
					 | 
				
			||||||
    <path
 | 
					 | 
				
			||||||
       id="path2498"
 | 
					 | 
				
			||||||
       style="color:#000000;fill:#ffffff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
       d="m 50.400391,46.882812 c -9.16879,0 -17.023438,7.2146 -17.023438,16.386719 v 0.0078 l 0.08984,71.369139 -2.302735,16.99219 31.3125,-8.31836 h 77.841802 c 9.16877,0 17.02344,-7.22425 17.02344,-16.39648 V 63.269531 c 0,-9.169496 -7.85031,-16.382463 -17.01563,-16.386719 h -0.008 z m 0,11.566407 h 89.917969 0.008 c 3.22151,0.0033 5.44922,2.346918 5.44922,4.820312 v 63.654299 c 0,2.47551 -2.23164,4.82031 -5.45703,4.82031 H 60.779297 l -15.908203,4.80664 0.162109,-0.9375 -0.08789,-72.343749 c 0,-2.475337 2.229739,-4.820312 5.455078,-4.820312 z"
 | 
					 | 
				
			||||||
       transform="matrix(0.26458333,0,0,0.26458333,51.147327,81.515579)" />
 | 
					 | 
				
			||||||
    <g
 | 
					 | 
				
			||||||
       id="path1011-6-2"
 | 
					 | 
				
			||||||
       transform="matrix(1.4536603,0,0,1.728146,-23.97473,-90.437157)"
 | 
					 | 
				
			||||||
       style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke:none;stroke-width:0.525121">
 | 
					 | 
				
			||||||
      <path
 | 
					 | 
				
			||||||
         style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#ffffff;stroke:none;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
         d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z"
 | 
					 | 
				
			||||||
         id="path7553" />
 | 
					 | 
				
			||||||
      <path
 | 
					 | 
				
			||||||
         style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#ffffff;stroke:none;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
         d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z"
 | 
					 | 
				
			||||||
         id="path7555" />
 | 
					 | 
				
			||||||
    </g>
 | 
					 | 
				
			||||||
    <g
 | 
					 | 
				
			||||||
       id="g1224"
 | 
					 | 
				
			||||||
       transform="matrix(1.4493527,0,0,1.6641427,-22.956963,-85.389973)"
 | 
					 | 
				
			||||||
       style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke:none;stroke-width:0.525121">
 | 
					 | 
				
			||||||
      <path
 | 
					 | 
				
			||||||
         style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#ffffff;stroke:none;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
         d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z"
 | 
					 | 
				
			||||||
         id="path1220" />
 | 
					 | 
				
			||||||
      <path
 | 
					 | 
				
			||||||
         style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#ffffff;stroke:none;-inkscape-stroke:none"
 | 
					 | 
				
			||||||
         d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z"
 | 
					 | 
				
			||||||
         id="path1222" />
 | 
					 | 
				
			||||||
    </g>
 | 
					 | 
				
			||||||
  </g>
 | 
					 | 
				
			||||||
  <g
 | 
					 | 
				
			||||||
     inkscape:groupmode="layer"
 | 
					 | 
				
			||||||
     id="layer3"
 | 
					 | 
				
			||||||
     inkscape:label="round icon preview"
 | 
					 | 
				
			||||||
     style="display:none">
 | 
					 | 
				
			||||||
    <path
 | 
					 | 
				
			||||||
       id="path18850-8-1"
 | 
					 | 
				
			||||||
       style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:0.255654"
 | 
					 | 
				
			||||||
       d="M 50.337488,80.973198 V 131.61213 H 101.65302 V 80.973198 Z m 25.676545,1.442307 h 0.555989 a 24.369387,24.369387 0 0 1 23.860308,21.232925 v 6.09963 a 24.369387,24.369387 0 0 1 -21.288308,21.19336 h 21.288308 v 0.0138 H 51.963792 v -0.0158 H 73.428179 A 24.369387,24.369387 0 0 1 51.963792,107.97535 v -2.49089 A 24.369387,24.369387 0 0 1 76.014033,82.415508 Z"
 | 
					 | 
				
			||||||
       transform="translate(-51.147326,-81.51558)" />
 | 
					 | 
				
			||||||
  </g>
 | 
					 | 
				
			||||||
</svg>
 | 
					 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 4.3 KiB  | 
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue