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" | ||||
| ) | ||||
| 
 | ||||
| // Auther is a generic interface to implement password and token based authentication and authorization | ||||
| type Auther interface { | ||||
| // Manager is a generic interface to implement password and token based authentication and authorization | ||||
| type Manager interface { | ||||
| 	// Authenticate checks username and password and returns a user if correct. The method | ||||
| 	// returns in constant-ish time, regardless of whether the user exists or the password is | ||||
| 	// correct or incorrect. | ||||
|  | @ -21,10 +21,7 @@ type Auther interface { | |||
| 	// Authorize returns nil if the given user has access to the given topic using the desired | ||||
| 	// permission. The user param may be nil to signal an anonymous user. | ||||
| 	Authorize(user *User, topic string, perm Permission) error | ||||
| } | ||||
| 
 | ||||
| // 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 | ||||
| 	// before it is stored in a persistence layer. | ||||
| 	AddUser(username, password string, role Role) error | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ const ( | |||
| 	intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost | ||||
| ) | ||||
| 
 | ||||
| // Auther-related queries | ||||
| // Manager-related queries | ||||
| const ( | ||||
| 	createAuthTablesQueries = ` | ||||
| 		BEGIN; | ||||
|  | @ -105,19 +105,18 @@ const ( | |||
| 	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. | ||||
| type SQLiteAuth struct { | ||||
| type SQLiteAuthManager struct { | ||||
| 	db           *sql.DB | ||||
| 	defaultRead  bool | ||||
| 	defaultWrite bool | ||||
| } | ||||
| 
 | ||||
| var _ Auther = (*SQLiteAuth)(nil) | ||||
| var _ Manager = (*SQLiteAuth)(nil) | ||||
| var _ Manager = (*SQLiteAuthManager)(nil) | ||||
| 
 | ||||
| // NewSQLiteAuth creates a new SQLiteAuth instance | ||||
| func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) { | ||||
| // NewSQLiteAuthManager creates a new SQLiteAuthManager instance | ||||
| func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteAuthManager, error) { | ||||
| 	db, err := sql.Open("sqlite3", filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -125,7 +124,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth | |||
| 	if err := setupAuthDB(db); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &SQLiteAuth{ | ||||
| 	return &SQLiteAuthManager{ | ||||
| 		db:           db, | ||||
| 		defaultRead:  defaultRead, | ||||
| 		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 | ||||
| // returns in constant-ish time, regardless of whether the user exists or the password is | ||||
| // correct or incorrect. | ||||
| func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { | ||||
| func (a *SQLiteAuthManager) Authenticate(username, password string) (*User, error) { | ||||
| 	if username == Everyone { | ||||
| 		return nil, ErrUnauthenticated | ||||
| 	} | ||||
|  | @ -151,7 +150,7 @@ func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { | |||
| 	return user, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) { | ||||
| func (a *SQLiteAuthManager) AuthenticateToken(token string) (*User, error) { | ||||
| 	user, err := a.userByToken(token) | ||||
| 	if err != nil { | ||||
| 		return nil, ErrUnauthenticated | ||||
|  | @ -160,7 +159,7 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) { | |||
| 	return user, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) CreateToken(user *User) (string, error) { | ||||
| func (a *SQLiteAuthManager) CreateToken(user *User) (string, error) { | ||||
| 	token := util.RandomString(tokenLength) | ||||
| 	expires := 1 // FIXME | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) RemoveToken(user *User) error { | ||||
| func (a *SQLiteAuthManager) RemoveToken(user *User) error { | ||||
| 	if user.Token == "" { | ||||
| 		return ErrUnauthorized | ||||
| 	} | ||||
|  | @ -179,7 +178,7 @@ func (a *SQLiteAuth) RemoveToken(user *User) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) ChangeSettings(user *User) error { | ||||
| func (a *SQLiteAuthManager) ChangeSettings(user *User) error { | ||||
| 	settings, err := json.Marshal(user.Prefs) | ||||
| 	if err != nil { | ||||
| 		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 | ||||
| // 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 { | ||||
| 		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) | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error { | ||||
| func (a *SQLiteAuthManager) resolvePerms(read, write bool, perm Permission) error { | ||||
| 	if perm == PermissionRead && read { | ||||
| 		return nil | ||||
| 	} 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 | ||||
| // 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) { | ||||
| 		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 | ||||
| // 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) { | ||||
| 		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 ("*"). | ||||
| func (a *SQLiteAuth) Users() ([]*User, error) { | ||||
| func (a *SQLiteAuthManager) Users() ([]*User, error) { | ||||
| 	rows, err := a.db.Query(selectUsernamesQuery) | ||||
| 	if err != nil { | ||||
| 		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. | ||||
| // 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 { | ||||
| 		return a.everyoneUser() | ||||
| 	} | ||||
|  | @ -307,7 +306,7 @@ func (a *SQLiteAuth) User(username string) (*User, error) { | |||
| 	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) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -315,7 +314,7 @@ func (a *SQLiteAuth) userByToken(token string) (*User, error) { | |||
| 	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() | ||||
| 	var username, hash, role string | ||||
| 	var prefs sql.NullString | ||||
|  | @ -346,7 +345,7 @@ func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) { | |||
| 	return user, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) everyoneUser() (*User, error) { | ||||
| func (a *SQLiteAuthManager) everyoneUser() (*User, error) { | ||||
| 	grants, err := a.readGrants(Everyone) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -359,7 +358,7 @@ func (a *SQLiteAuth) everyoneUser() (*User, error) { | |||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) { | ||||
| func (a *SQLiteAuthManager) readGrants(username string) ([]Grant, error) { | ||||
| 	rows, err := a.db.Query(selectUserAccessQuery, username) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -384,7 +383,7 @@ func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) { | |||
| } | ||||
| 
 | ||||
| // 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) | ||||
| 	if err != nil { | ||||
| 		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, | ||||
| // 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) { | ||||
| 		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 | ||||
| // 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) { | ||||
| 		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 | ||||
| // 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 != "" { | ||||
| 		return ErrInvalidArgument | ||||
| 	} 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 | ||||
| func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) { | ||||
| func (a *SQLiteAuthManager) DefaultAccess() (read bool, write bool) { | ||||
| 	return a.defaultRead, a.defaultWrite | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -235,9 +235,9 @@ func TestSQLiteAuth_ChangeRole(t *testing.T) { | |||
| 	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") | ||||
| 	a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite) | ||||
| 	a, err := auth.NewSQLiteAuthManager(filename, defaultRead, defaultWrite) | ||||
| 	require.Nil(t, err) | ||||
| 	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.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: "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{ | ||||
|  | @ -141,6 +143,8 @@ func execServe(c *cli.Context) error { | |||
| 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") | ||||
| 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") | ||||
| 	behindProxy := c.Bool("behind-proxy") | ||||
| 	enableSignup := c.Bool("enable-signup") | ||||
| 	enableLogin := c.Bool("enable-login") | ||||
| 
 | ||||
| 	// Check values | ||||
| 	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { | ||||
|  | @ -268,6 +272,8 @@ func execServe(c *cli.Context) error { | |||
| 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | ||||
| 	conf.BehindProxy = behindProxy | ||||
| 	conf.EnableWeb = enableWeb | ||||
| 	conf.EnableSignup = enableSignup | ||||
| 	conf.EnableLogin = enableLogin | ||||
| 	conf.Version = c.App.Version | ||||
| 
 | ||||
| 	// 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" | ||||
| 	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) { | ||||
|  |  | |||
|  | @ -100,6 +100,10 @@ type Config struct { | |||
| 	VisitorEmailLimitReplenish           time.Duration | ||||
| 	BehindProxy                          bool | ||||
| 	EnableWeb                            bool | ||||
| 	EnableSignup                         bool | ||||
| 	EnableLogin                          bool | ||||
| 	EnableEmailConfirm                   bool | ||||
| 	EnableResetPassword                  bool | ||||
| 	Version                              string // injected by App | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										111
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										111
									
								
								server/server.go
									
										
									
									
									
								
							|  | @ -38,10 +38,7 @@ import ( | |||
| 	TODO | ||||
| 		expire tokens | ||||
| 		auto-refresh tokens from UI | ||||
| 		pricing page | ||||
| 		home page | ||||
| 		reserve topics | ||||
| 
 | ||||
| 		Pages: | ||||
| 		- Home | ||||
| 		- Signup | ||||
|  | @ -52,11 +49,6 @@ import ( | |||
| 		- 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> | ||||
| 	firebaseClient    *firebaseClient | ||||
| 	messages          int64 | ||||
| 	auth              auth.Auther | ||||
| 	auth              auth.Manager | ||||
| 	messageCache      *messageCache | ||||
| 	fileCache         *fileCache | ||||
| 	closeChan         chan bool | ||||
|  | @ -98,10 +90,11 @@ var ( | |||
| 
 | ||||
| 	webConfigPath                  = "/config.js" | ||||
| 	userStatsPath                  = "/user/stats" // FIXME get rid of this in favor of /user/account | ||||
| 	userTokenPath               = "/user/token" | ||||
| 	userAccountPath             = "/user/account" | ||||
| 	userSubscriptionPath        = "/user/subscription" | ||||
| 	userSubscriptionDeleteRegex = regexp.MustCompile(`^/user/subscription/([-_A-Za-z0-9]{16})$`) | ||||
| 	accountPath                    = "/v1/account" | ||||
| 	accountTokenPath               = "/v1/account/token" | ||||
| 	accountSettingsPath            = "/v1/account/settings" | ||||
| 	accountSubscriptionPath        = "/v1/account/subscription" | ||||
| 	accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`) | ||||
| 	matrixPushPath                 = "/_matrix/push/v1/notify" | ||||
| 	staticRegex                    = regexp.MustCompile(`^/static/.+`) | ||||
| 	docsRegex                      = regexp.MustCompile(`^/docs(|/.*)$`) | ||||
|  | @ -160,9 +153,9 @@ func New(conf *Config) (*Server, error) { | |||
| 			return nil, err | ||||
| 		} | ||||
| 	} | ||||
| 	var auther auth.Auther | ||||
| 	var auther auth.Manager | ||||
| 	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 { | ||||
| 			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) | ||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { | ||||
| 		return s.handleUserStats(w, r, v) | ||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == userTokenPath { | ||||
| 		return s.handleUserTokenCreate(w, r, v) | ||||
| 	} else if r.Method == http.MethodDelete && r.URL.Path == userTokenPath { | ||||
| 		return s.handleUserTokenDelete(w, r, v) | ||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath { | ||||
| 		return s.handleUserAccount(w, r, v) | ||||
| 	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath { | ||||
| 		return s.handleUserAccountUpdate(w, r, v) | ||||
| 	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userSubscriptionPath { | ||||
| 		return s.handleUserSubscriptionAdd(w, r, v) | ||||
| 	} else if r.Method == http.MethodDelete && userSubscriptionDeleteRegex.MatchString(r.URL.Path) { | ||||
| 		return s.handleUserSubscriptionDelete(w, r, v) | ||||
| 	} else if r.Method == http.MethodPost && r.URL.Path == accountPath { | ||||
| 		return s.handleUserAccountCreate(w, r, v) | ||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == accountTokenPath { | ||||
| 		return s.handleAccountTokenGet(w, r, v) | ||||
| 	} else if r.Method == http.MethodDelete && r.URL.Path == accountTokenPath { | ||||
| 		return s.handleAccountTokenDelete(w, r, v) | ||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == accountSettingsPath { | ||||
| 		return s.handleAccountSettingsGet(w, r, v) | ||||
| 	} else if r.Method == http.MethodPost && r.URL.Path == accountSettingsPath { | ||||
| 		return s.handleAccountSettingsPost(w, r, v) | ||||
| 	} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath { | ||||
| 		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 { | ||||
| 		return s.handleMatrixDiscovery(w) | ||||
| 	} 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 | ||||
| } | ||||
| 
 | ||||
| type tokenAuthResponse struct { | ||||
| 	Token string `json:"token"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||
| func (s *Server) handleAccountTokenGet(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||
| 	// TODO rate limit | ||||
| 	if v.user == nil { | ||||
| 		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("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||
| 	response := &tokenAuthResponse{ | ||||
| 	response := &apiAccountTokenResponse{ | ||||
| 		Token: token, | ||||
| 	} | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| 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 | ||||
| 	if v.user == nil || v.user.Token == "" { | ||||
| 		return errHTTPUnauthorized | ||||
|  | @ -477,24 +468,10 @@ func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type userPlanResponse struct { | ||||
| 	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 { | ||||
| func (s *Server) handleAccountSettingsGet(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||
| 	w.Header().Set("Content-Type", "application/json") | ||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||
| 	response := &userAccountResponse{} | ||||
| 	response := &apiAccountSettingsResponse{} | ||||
| 	if v.user != nil { | ||||
| 		response.Username = v.user.Name | ||||
| 		response.Role = string(v.user.Role) | ||||
|  | @ -510,7 +487,7 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi | |||
| 			} | ||||
| 		} | ||||
| 	} else { | ||||
| 		response = &userAccountResponse{ | ||||
| 		response = &apiAccountSettingsResponse{ | ||||
| 			Username: auth.Everyone, | ||||
| 			Role:     string(auth.RoleAnonymous), | ||||
| 		} | ||||
|  | @ -521,7 +498,31 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi | |||
| 	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 { | ||||
| 		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) | ||||
| } | ||||
| 
 | ||||
| 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 { | ||||
| 		return errors.New("no user") | ||||
| 	} | ||||
|  | @ -598,13 +599,13 @@ func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Reques | |||
| 	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 { | ||||
| 		return errors.New("no user") | ||||
| 	} | ||||
| 	w.Header().Set("Content-Type", "application/json") | ||||
| 	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 { | ||||
| 		return errHTTPInternalErrorInvalidFilePath // FIXME | ||||
| 	} | ||||
|  |  | |||
|  | @ -28,10 +28,10 @@ var ( | |||
| // The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable. | ||||
| type firebaseClient struct { | ||||
| 	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{ | ||||
| 		sender: sender, | ||||
| 		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. | ||||
| //   - 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. | ||||
| 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 apnsConfig *messaging.APNSConfig | ||||
| 	switch m.Event { | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	"heckel.io/ntfy/auth" | ||||
| 	"net/http" | ||||
| 	"net/netip" | ||||
| 	"time" | ||||
|  | @ -213,3 +214,26 @@ func (q *queryFilter) Pass(msg *message) bool { | |||
| 	} | ||||
| 	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 */ | ||||
| 
 | ||||
| html, body { | ||||
| #site { | ||||
|     font-family: 'Roboto', sans-serif; | ||||
|     font-weight: 400; | ||||
|     font-size: 1.1em; | ||||
|  | @ -9,22 +9,16 @@ html, body { | |||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| html { | ||||
|     /* 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 { | ||||
| #site a, a:visited { | ||||
|     color: #338574; | ||||
| } | ||||
| 
 | ||||
| a:hover { | ||||
| #site a:hover { | ||||
|     text-decoration: none; | ||||
|     color: #317f6f; | ||||
| } | ||||
| 
 | ||||
| h1 { | ||||
| #site h1 { | ||||
|     margin-top: 35px; | ||||
|     margin-bottom: 30px; | ||||
|     font-size: 2.5em; | ||||
|  | @ -34,7 +28,7 @@ h1 { | |||
|     color: #666; | ||||
| } | ||||
| 
 | ||||
| h2 { | ||||
| #site h2 { | ||||
|     margin-top: 30px; | ||||
|     margin-bottom: 5px; | ||||
|     font-size: 1.8em; | ||||
|  | @ -42,7 +36,7 @@ h2 { | |||
|     color: #333; | ||||
| } | ||||
| 
 | ||||
| h3 { | ||||
| #site h3 { | ||||
|     margin-top: 25px; | ||||
|     margin-bottom: 5px; | ||||
|     font-size: 1.3em; | ||||
|  | @ -50,28 +44,28 @@ h3 { | |||
|     color: #333; | ||||
| } | ||||
| 
 | ||||
| p { | ||||
| #site p { | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 20px; | ||||
|     line-height: 160%; | ||||
|     font-weight: 400; | ||||
| } | ||||
| 
 | ||||
| p.smallMarginBottom { | ||||
| #site p.smallMarginBottom { | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
| 
 | ||||
| b { | ||||
| #site b { | ||||
|     font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| tt { | ||||
| #site tt { | ||||
|     background: #eee; | ||||
|     padding: 2px 7px; | ||||
|     border-radius: 3px; | ||||
| } | ||||
| 
 | ||||
| code { | ||||
| #site code { | ||||
|     display: block; | ||||
|     background: #eee; | ||||
|     font-family: monospace; | ||||
|  | @ -85,18 +79,18 @@ code { | |||
| 
 | ||||
| /* Main page */ | ||||
| 
 | ||||
| #main { | ||||
| #site #main { | ||||
|     max-width: 900px; | ||||
|     margin: 0 auto 50px auto; | ||||
|     padding: 0 10px; | ||||
| } | ||||
| 
 | ||||
| #error { | ||||
| #site #error { | ||||
|     color: darkred; | ||||
|     font-style: italic; | ||||
| } | ||||
| 
 | ||||
| #ironicCenterTagDontFreakOut { | ||||
| #site #ironicCenterTagDontFreakOut { | ||||
|     color: #666; | ||||
| } | ||||
| 
 | ||||
|  | @ -120,22 +114,22 @@ code { | |||
| 
 | ||||
| /* Figures */ | ||||
| 
 | ||||
| figure { | ||||
| #site figure { | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| figure img, figure video { | ||||
| #site figure img, figure video { | ||||
|     filter: drop-shadow(3px 3px 3px #ccc); | ||||
|     border-radius: 7px; | ||||
|     max-width: 100%; | ||||
| } | ||||
| 
 | ||||
| figure video { | ||||
| #site figure video { | ||||
|     width: 100%; | ||||
|     max-height: 450px; | ||||
| } | ||||
| 
 | ||||
| figcaption { | ||||
| #site figcaption { | ||||
|     text-align: center; | ||||
|     font-style: italic; | ||||
|     padding-top: 10px; | ||||
|  | @ -143,18 +137,18 @@ figcaption { | |||
| 
 | ||||
| /* Screenshots */ | ||||
| 
 | ||||
| #screenshots { | ||||
| #site #screenshots { | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| #screenshots img { | ||||
| #site #screenshots img { | ||||
|     height: 190px; | ||||
|     margin: 3px; | ||||
|     border-radius: 5px; | ||||
|     filter: drop-shadow(2px 2px 2px #ddd); | ||||
| } | ||||
| 
 | ||||
| #screenshots .nowrap { | ||||
| #site #screenshots .nowrap { | ||||
|     white-space: nowrap; | ||||
| } | ||||
| 
 | ||||
|  | @ -220,23 +214,23 @@ figcaption { | |||
| 
 | ||||
| /* Header */ | ||||
| 
 | ||||
| #header { | ||||
| #site #header { | ||||
|     background: #338574; | ||||
|     height: 130px; | ||||
| } | ||||
| 
 | ||||
| #header #headerBox { | ||||
| #site #header #headerBox { | ||||
|     max-width: 900px; | ||||
|     margin: 0 auto; | ||||
|     padding: 0 10px; | ||||
| } | ||||
| 
 | ||||
| #header #logo { | ||||
| #site #header #logo { | ||||
|     margin-top: 23px; | ||||
|     float: left; | ||||
| } | ||||
| 
 | ||||
| #header #name { | ||||
| #site #header #name { | ||||
|     float: left; | ||||
|     color: white; | ||||
|     font-size: 2.6em; | ||||
|  | @ -244,28 +238,28 @@ figcaption { | |||
|     margin: 35px 0 0 20px; | ||||
| } | ||||
| 
 | ||||
| #header ol { | ||||
| #site #header ol { | ||||
|     list-style-type: none; | ||||
|     float: right; | ||||
|     margin-top: 80px; | ||||
| } | ||||
| 
 | ||||
| #header ol li { | ||||
| #site #header ol li { | ||||
|     display: inline-block; | ||||
|     margin: 0 10px; | ||||
|     font-weight: 400; | ||||
| } | ||||
| 
 | ||||
| #header ol li a, nav ol li a:visited { | ||||
| #site #header ol li a, nav ol li a:visited { | ||||
|     color: white; | ||||
|     text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| #header ol li a:hover { | ||||
| #site #header ol li a:hover { | ||||
|     text-decoration: underline; | ||||
| } | ||||
| 
 | ||||
| li { | ||||
| #site li { | ||||
|     padding: 4px 0; | ||||
|     margin: 4px 0; | ||||
|     font-size: 0.9em; | ||||
|  |  | |||
|  | @ -6,9 +6,9 @@ import { | |||
|     topicUrlAuth, | ||||
|     topicUrlJsonPoll, | ||||
|     topicUrlJsonPollWithSince, | ||||
|     userAccountUrl, | ||||
|     userTokenUrl, | ||||
|     userStatsUrl, userSubscriptionUrl, userSubscriptionDeleteUrl | ||||
|     accountSettingsUrl, | ||||
|     accountTokenUrl, | ||||
|     userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl | ||||
| } from "./utils"; | ||||
| import userManager from "./UserManager"; | ||||
| 
 | ||||
|  | @ -120,7 +120,7 @@ class Api { | |||
|     } | ||||
| 
 | ||||
|     async login(baseUrl, user) { | ||||
|         const url = userTokenUrl(baseUrl); | ||||
|         const url = accountTokenUrl(baseUrl); | ||||
|         console.log(`[Api] Checking auth for ${url}`); | ||||
|         const response = await fetch(url, { | ||||
|             headers: maybeWithBasicAuth({}, user) | ||||
|  | @ -136,7 +136,7 @@ class Api { | |||
|     } | ||||
| 
 | ||||
|     async logout(baseUrl, token) { | ||||
|         const url = userTokenUrl(baseUrl); | ||||
|         const url = accountTokenUrl(baseUrl); | ||||
|         console.log(`[Api] Logging out from ${url} using token ${token}`); | ||||
|         const response = await fetch(url, { | ||||
|             method: "DELETE", | ||||
|  | @ -159,8 +159,24 @@ class Api { | |||
|         return stats; | ||||
|     } | ||||
| 
 | ||||
|     async userAccount(baseUrl, token) { | ||||
|         const url = userAccountUrl(baseUrl); | ||||
|     async createAccount(baseUrl, username, password) { | ||||
|         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}`); | ||||
|         const response = await fetch(url, { | ||||
|             headers: maybeWithBearerAuth({}, token) | ||||
|  | @ -173,8 +189,8 @@ class Api { | |||
|         return account; | ||||
|     } | ||||
| 
 | ||||
|     async updateUserAccount(baseUrl, token, payload) { | ||||
|         const url = userAccountUrl(baseUrl); | ||||
|     async updateAccountSettings(baseUrl, token, payload) { | ||||
|         const url = accountSettingsUrl(baseUrl); | ||||
|         const body = JSON.stringify(payload); | ||||
|         console.log(`[Api] Updating user account ${url}: ${body}`); | ||||
|         const response = await fetch(url, { | ||||
|  | @ -187,8 +203,8 @@ class Api { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async userSubscriptionAdd(baseUrl, token, payload) { | ||||
|         const url = userSubscriptionUrl(baseUrl); | ||||
|     async addAccountSubscription(baseUrl, token, payload) { | ||||
|         const url = accountSubscriptionUrl(baseUrl); | ||||
|         const body = JSON.stringify(payload); | ||||
|         console.log(`[Api] Adding user subscription ${url}: ${body}`); | ||||
|         const response = await fetch(url, { | ||||
|  | @ -204,8 +220,8 @@ class Api { | |||
|         return subscription; | ||||
|     } | ||||
| 
 | ||||
|     async userSubscriptionDelete(baseUrl, token, remoteId) { | ||||
|         const url = userSubscriptionDeleteUrl(baseUrl, remoteId); | ||||
|     async deleteAccountSubscription(baseUrl, token, remoteId) { | ||||
|         const url = accountSubscriptionSingleUrl(baseUrl, remoteId); | ||||
|         console.log(`[Api] Removing user subscription ${url}`); | ||||
|         const response = await fetch(url, { | ||||
|             method: "DELETE", | ||||
|  |  | |||
|  | @ -19,10 +19,11 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ | |||
| export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; | ||||
| export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); | ||||
| export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; | ||||
| export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`; | ||||
| export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; | ||||
| export const userSubscriptionUrl = (baseUrl) => `${baseUrl}/user/subscription`; | ||||
| export const userSubscriptionDeleteUrl = (baseUrl, id) => `${baseUrl}/user/subscription/${id}`; | ||||
| export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; | ||||
| export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; | ||||
| export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; | ||||
| 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 expandUrl = (url) => [`https://${url}`, `http://${url}`]; | ||||
| export const expandSecureUrl = (url) => `https://${url}`; | ||||
|  |  | |||
|  | @ -115,7 +115,7 @@ const SettingsIcons = (props) => { | |||
|         handleClose(event); | ||||
|         await subscriptionManager.remove(props.subscription.id); | ||||
|         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
 | ||||
|         if (newSelected) { | ||||
|  |  | |||
|  | @ -91,7 +91,7 @@ const Layout = () => { | |||
| 
 | ||||
|     useEffect(() => { | ||||
|         (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.language) { | ||||
|                     await i18n.changeLanguage(account.language); | ||||
|  |  | |||
|  | @ -8,6 +8,8 @@ import Box from "@mui/material/Box"; | |||
| import api from "../app/Api"; | ||||
| import routes from "./routes"; | ||||
| import session from "../app/Session"; | ||||
| import logo from "../img/ntfy2.svg"; | ||||
| import {NavLink} from "react-router-dom"; | ||||
| 
 | ||||
| const Login = () => { | ||||
|     const handleSubmit = async (event) => { | ||||
|  | @ -24,24 +26,28 @@ const Login = () => { | |||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|         <Box | ||||
|             sx={{ | ||||
|                     marginTop: 8, | ||||
|                 display: 'flex', | ||||
|                 flexGrow: 1, | ||||
|                 justifyContent: 'center', | ||||
|                 flexDirection: 'column', | ||||
|                 alignContent: 'center', | ||||
|                 alignItems: 'center', | ||||
|                 height: '100vh' | ||||
|             }} | ||||
|         > | ||||
|                 <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}> | ||||
|                     <LockOutlinedIcon/> | ||||
|                 </Avatar> | ||||
|                 <Typography component="h1" variant="h5"> | ||||
|                     Sign in | ||||
|             <Avatar | ||||
|                 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}}> | ||||
|             <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> | ||||
|                 <TextField | ||||
|                         margin="normal" | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     id="username" | ||||
|  | @ -50,7 +56,7 @@ const Login = () => { | |||
|                     autoFocus | ||||
|                 /> | ||||
|                 <TextField | ||||
|                         margin="normal" | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="password" | ||||
|  | @ -59,33 +65,20 @@ const Login = () => { | |||
|                     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}} | ||||
|                     sx={{mt: 2, mb: 2}} | ||||
|                 > | ||||
|                         Sign In | ||||
|                     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 sx={{width: "100%"}}> | ||||
|                     <NavLink to="#" variant="body1" sx={{float: "left"}}>Reset password</NavLink> | ||||
|                     <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign Up</NavLink></div> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </Box> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -73,7 +73,7 @@ const Sound = () => { | |||
|     const handleChange = async (ev) => { | ||||
|         await prefs.setSound(ev.target.value); | ||||
|         if (session.exists()) { | ||||
|             await api.updateUserAccount("http://localhost:2586", session.token(), { | ||||
|             await api.updateAccountSettings("http://localhost:2586", session.token(), { | ||||
|                 notification: { | ||||
|                     sound: ev.target.value | ||||
|                 } | ||||
|  | @ -113,7 +113,7 @@ const MinPriority = () => { | |||
|     const handleChange = async (ev) => { | ||||
|         await prefs.setMinPriority(ev.target.value); | ||||
|         if (session.exists()) { | ||||
|             await api.updateUserAccount("http://localhost:2586", session.token(), { | ||||
|             await api.updateAccountSettings("http://localhost:2586", session.token(), { | ||||
|                 notification: { | ||||
|                     min_priority: ev.target.value | ||||
|                 } | ||||
|  | @ -163,7 +163,7 @@ const DeleteAfter = () => { | |||
|     const handleChange = async (ev) => { | ||||
|         await prefs.setDeleteAfter(ev.target.value); | ||||
|         if (session.exists()) { | ||||
|             await api.updateUserAccount("http://localhost:2586", session.token(), { | ||||
|             await api.updateAccountSettings("http://localhost:2586", session.token(), { | ||||
|                 notification: { | ||||
|                     delete_after: ev.target.value | ||||
|                 } | ||||
|  | @ -467,7 +467,7 @@ const Language = () => { | |||
|     const handleChange = async (ev) => { | ||||
|         await i18n.changeLanguage(ev.target.value); | ||||
|         if (session.exists()) { | ||||
|             await api.updateUserAccount("http://localhost:2586", session.token(), { | ||||
|             await api.updateAccountSettings("http://localhost:2586", session.token(), { | ||||
|                 language: ev.target.value | ||||
|             }); | ||||
|         } | ||||
|  |  | |||
|  | @ -1,24 +1,27 @@ | |||
| import * as React from 'react'; | ||||
| import {Avatar, Checkbox, FormControlLabel, Grid, Link, Stack} from "@mui/material"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import Container from "@mui/material/Container"; | ||||
| import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; | ||||
| import {Avatar, Link} from "@mui/material"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import api from "../app/Api"; | ||||
| import {useNavigate} from "react-router-dom"; | ||||
| import routes from "./routes"; | ||||
| 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 handleSubmit = async (event) => { | ||||
|         event.preventDefault(); | ||||
|         const data = new FormData(event.currentTarget); | ||||
|         const username = data.get('username'); | ||||
|         const password = data.get('password'); | ||||
|         const user = { | ||||
|             username: data.get('username'), | ||||
|             password: data.get('password'), | ||||
|         } | ||||
|             username: username, | ||||
|             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); | ||||
|         console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`); | ||||
|         session.store(user.username, token); | ||||
|  | @ -26,24 +29,28 @@ const Signup = () => { | |||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|         <Box | ||||
|             sx={{ | ||||
|                     marginTop: 8, | ||||
|                 display: 'flex', | ||||
|                 flexGrow: 1, | ||||
|                 justifyContent: 'center', | ||||
|                 flexDirection: 'column', | ||||
|                 alignContent: 'center', | ||||
|                 alignItems: 'center', | ||||
|                 height: '100vh' | ||||
|             }} | ||||
|         > | ||||
|                 <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}> | ||||
|                     <LockOutlinedIcon/> | ||||
|                 </Avatar> | ||||
|                 <Typography component="h1" variant="h5"> | ||||
|                     Sign in | ||||
|             <Avatar | ||||
|                 sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} | ||||
|                 src={logo} | ||||
|                 variant="rounded" | ||||
|             /> | ||||
|             <Typography sx={{ typography: 'h6' }}> | ||||
|                 Create a ntfy account | ||||
|             </Typography> | ||||
|                 <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}> | ||||
|             <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> | ||||
|                 <TextField | ||||
|                         margin="normal" | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     id="username" | ||||
|  | @ -52,7 +59,7 @@ const Signup = () => { | |||
|                     autoFocus | ||||
|                 /> | ||||
|                 <TextField | ||||
|                         margin="normal" | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="password" | ||||
|  | @ -61,33 +68,30 @@ const Signup = () => { | |||
|                     id="password" | ||||
|                     autoComplete="current-password" | ||||
|                 /> | ||||
|                     <FormControlLabel | ||||
|                         control={<Checkbox value="remember" color="primary"/>} | ||||
|                         label="Remember me" | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="confirm-password" | ||||
|                     label="Confirm password" | ||||
|                     type="password" | ||||
|                     id="confirm-password" | ||||
|                 /> | ||||
|                 <Button | ||||
|                     type="submit" | ||||
|                     fullWidth | ||||
|                     variant="contained" | ||||
|                         sx={{mt: 3, mb: 2}} | ||||
|                     sx={{mt: 2, mb: 2}} | ||||
|                 > | ||||
|                     Sign up | ||||
|                 </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> | ||||
|             <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.pricing} activeStyle>Pricing</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>} | ||||
|                         <li><NavLink to={routes.app} activeStyle>Open app</NavLink></li> | ||||
|                     </ol> | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ const SubscribeDialog = (props) => { | |||
|         const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; | ||||
|         const subscription = await subscriptionManager.add(actualBaseUrl, topic); | ||||
|         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, | ||||
|                 topic: topic | ||||
|             }); | ||||
|  |  | |||
|  | @ -64,7 +64,7 @@ export const useAutoSubscribe = (subscriptions, selected) => { | |||
|             (async () => { | ||||
|                 const subscription = await subscriptionManager.add(baseUrl, params.topic); | ||||
|                 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, | ||||
|                         topic: params.topic | ||||
|                     }); | ||||
|  |  | |||
|  | @ -1,255 +1 @@ | |||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- 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> | ||||
| <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> | ||||
| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 4.3 KiB | 
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue