Stuff
parent
c35e5b33d1
commit
c2f16f740b
|
@ -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
|
||||
}
|
||||
|
||||
|
|
127
server/server.go
127
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
|
||||
|
@ -96,18 +88,19 @@ var (
|
|||
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)$`)
|
||||
|
||||
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})$`)
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
webConfigPath = "/config.js"
|
||||
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
|
||||
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(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
|
||||
//go:embed site
|
||||
webFs embed.FS
|
||||
|
@ -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,68 +26,59 @@ const Login = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
|
||||
<LockOutlinedIcon/>
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<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, 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
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
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>
|
||||
</Button>
|
||||
<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,68 +29,69 @@ const Signup = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
|
||||
<LockOutlinedIcon/>
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
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 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>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<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, 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"
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
fullWidth
|
||||
name="confirm-password"
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{mt: 2, mb: 2}}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</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…
Reference in New Issue