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