pull/584/head
binwiederhier 2022-12-14 23:11:22 -05:00
parent c35e5b33d1
commit c2f16f740b
21 changed files with 332 additions and 547 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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) {

View File

@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;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